feat: 스코어러 v7 + 검색 대기 보류 — 주의 후보 일치는 다시 보라는 뜻이지 알려진 권리 대상이 아니다
codex F1: 보류·반려로 자동 생성된 주의 후보(watchlist)와의 일치가 확정 항목과 같은 80/high 를 받아, 검색 장애로 보류한 정상 이미지의 재제출이 알려진 권리 대상처럼 올라왔다. ① 스코어러: knowledge_entry_status == watchlist 는 별도 축 40/20(상한 40, 단독 medium), 확정(승격·수동 등록)만 80. 한 줄 요약도 '주의 후보 일치 0.95' 로 구분한다. ② 스토어: record_decision(held) 가 has_coverage_gap()(실패·일일 한도 미수행)이면 주의 후보를 만들지 않고 감사에 '검색 대기 (주의 후보 미생성)' 를 남긴다. 반려는 커버리지와 무관하게 생성. 가이드·PROJECT_MAP 갱신, 다이어그램 태그 v7 재생성(manifest 5종). verify.ps1: ruff clean, 672 passed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
This commit is contained in:
parent
30772e93cf
commit
92fc8094d4
14 changed files with 181 additions and 19 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
# Project INDEX
|
# Project INDEX
|
||||||
|
|
||||||
|
- 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 · **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 · **심사 가이드 다이어그램** — `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 표 추가.
|
||||||
|
|
|
||||||
|
|
@ -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` 은 가중치/버킷/밴드 변경 시 반드시 올린다**. `RiskScorer(trusted_hosts)` 는 지식DB 의 활성 `trusted_host` 항목(이름·별칭이 도메인)을 받아 그 호스트에서 발견된 웹 탐지 행을 0점 처리한다 — **행 단위 제외**라 자사 사본과 스크래퍼 게시가 같이 있으면 후자는 그대로 센다. v5/v6 축 예산표(등록 일치 80 · 웹 존재 40 한 풀 · 인물 30/10 · 검색 승격 15 · 라벨 10 · 미수행(일일 한도)·실패는 하한 30, disabled·거버넌스 스위치 미수행은 0)는 파일 상단 주석이 정본 — 검색결과 사본 행은 `knowledge_entry_id` 부재 + `url`/`source_evidence_ids` 로 웹 풀에 간다. `breakdown.headline` 은 점수와 무관한 한 줄 요약(`_Tally`·`_headline`) — 큐 '상위 근거' 칸과 케이스 요약 보드 첫 줄이 이것을 쓴다 |
|
| `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` 로 웹 풀에 간다. `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 폐기 |
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@
|
||||||
"type": "backend",
|
"type": "backend",
|
||||||
"label": "RiskScorer",
|
"label": "RiskScorer",
|
||||||
"sublabel": "한 풀 · 제공자별 버킷",
|
"sublabel": "한 풀 · 제공자별 버킷",
|
||||||
"tag": "v6",
|
"tag": "v7",
|
||||||
"stage": 2,
|
"stage": 2,
|
||||||
"row": 1
|
"row": 1
|
||||||
},
|
},
|
||||||
|
|
@ -294,7 +294,7 @@
|
||||||
"title": "주 경로",
|
"title": "주 경로",
|
||||||
"items": [
|
"items": [
|
||||||
"외부 응답은 데이터 등급 검사를 통과한 필드만 Evidence 가 된다",
|
"외부 응답은 데이터 등급 검사를 통과한 필드만 Evidence 가 된다",
|
||||||
"RiskScorer v6 는 웹 존재를 제공자 무관 한 풀로 센다 (presence 40 / entity 10)",
|
"RiskScorer v7 는 웹 존재를 제공자 무관 한 풀로 센다 (presence 40 / entity 10)",
|
||||||
"점수 · 밴드 · 사유는 payload 로 직렬화되어 evidence 테이블에 남는다",
|
"점수 · 밴드 · 사유는 payload 로 직렬화되어 evidence 테이블에 남는다",
|
||||||
"신뢰 출처(자사·계약처 도메인)에서 발견된 행은 0점이다 — 행 단위 제외라 스크래퍼 게시는 그대로 센다"
|
"신뢰 출처(자사·계약처 도메인)에서 발견된 행은 0점이다 — 행 단위 제외라 스크래퍼 게시는 그대로 센다"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
BIN
docs/diagrams/evidence-dataflow.html
generated
BIN
docs/diagrams/evidence-dataflow.html
generated
Binary file not shown.
|
|
@ -6,24 +6,24 @@
|
||||||
"type": "architecture",
|
"type": "architecture",
|
||||||
"spec": "system-architecture.architecture.json",
|
"spec": "system-architecture.architecture.json",
|
||||||
"html": "system-architecture.html",
|
"html": "system-architecture.html",
|
||||||
"specSha256": "48900a6970d64c978163dac06894150b3ff9636eca84efcbda999bf46deb2750",
|
"specSha256": "c72af7a58e0383cccc9ad6174f5035c720164d5db3418623af0a14d21fd47a7d",
|
||||||
"htmlSha256": "f09a4482dc03332dfb959853ef64ff2ba723891e223a4906cff8719794469d17",
|
"htmlSha256": "03e4e43d660e934f168444692c18821ae9aecdc31587015b75d61e32c0c8968e",
|
||||||
"htmlBytes": 816524
|
"htmlBytes": 816524
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "dataflow",
|
"type": "dataflow",
|
||||||
"spec": "evidence-dataflow.dataflow.json",
|
"spec": "evidence-dataflow.dataflow.json",
|
||||||
"html": "evidence-dataflow.html",
|
"html": "evidence-dataflow.html",
|
||||||
"specSha256": "88c1f67a6b34d1a31ed15a730f84fb6c474deab1ef81a582b87f30ec3157f0a3",
|
"specSha256": "9b6ffffdd65c5dfecec1e5b682617d68df2211e12928677a9b72f6ba9589e7ea",
|
||||||
"htmlSha256": "7b5f80dd87bbe9bee99aef1a311312bfd5d79d4508a2226a3af0329cdb621e79",
|
"htmlSha256": "0d650a9b93674672ec503b68a8b615735444c247221a49f8d938e9eda07d962f",
|
||||||
"htmlBytes": 823181
|
"htmlBytes": 823181
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "workflow",
|
"type": "workflow",
|
||||||
"spec": "operator-review-workflow.workflow.json",
|
"spec": "operator-review-workflow.workflow.json",
|
||||||
"html": "operator-review-workflow.html",
|
"html": "operator-review-workflow.html",
|
||||||
"specSha256": "d9158494d38c48b3a7ddc4cd55b8a2c6576d39ff7a94c9be25465eabbca41d39",
|
"specSha256": "750cac82931fc4a4b621fd95806d10a551bad9fd1e4190542972b6389857700c",
|
||||||
"htmlSha256": "e51d09c4251fb762c63142e870d6b58aef7d8f5ca6857875248dc9ee56ae0e7f",
|
"htmlSha256": "7a74e1158e3390b78d5f4733d28ddcbeaa9c2ca278500e81ab0ca09d69a7592d",
|
||||||
"htmlBytes": 818466
|
"htmlBytes": 818466
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
BIN
docs/diagrams/operator-review-workflow.html
generated
BIN
docs/diagrams/operator-review-workflow.html
generated
Binary file not shown.
|
|
@ -175,7 +175,7 @@
|
||||||
"type": "backend",
|
"type": "backend",
|
||||||
"label": "위험도 산출",
|
"label": "위험도 산출",
|
||||||
"sublabel": "점수 · 밴드 · 사유",
|
"sublabel": "점수 · 밴드 · 사유",
|
||||||
"tag": "v6",
|
"tag": "v7",
|
||||||
"width": 124
|
"width": 124
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@
|
||||||
"type": "backend",
|
"type": "backend",
|
||||||
"label": "분석 · 채점",
|
"label": "분석 · 채점",
|
||||||
"sublabel": "지문 · 얼굴 · 출처",
|
"sublabel": "지문 · 얼굴 · 출처",
|
||||||
"tag": "RiskScorer v6",
|
"tag": "RiskScorer v7",
|
||||||
"pos": [
|
"pos": [
|
||||||
678,
|
678,
|
||||||
60
|
60
|
||||||
|
|
|
||||||
BIN
docs/diagrams/system-architecture.html
generated
BIN
docs/diagrams/system-architecture.html
generated
Binary file not shown.
|
|
@ -23,7 +23,8 @@
|
||||||
|
|
||||||
| 한 줄 요약에 나오는 말 | 뜻 | 판정에서의 무게 |
|
| 한 줄 요약에 나오는 말 | 뜻 | 판정에서의 무게 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **등록 권리 대상 일치 0.95** | 기준 DB 에 등록된 이미지(반려·보류 이력, 수동 등록)와 같은 이미지 | 단독으로 높음. 이전 판정과 같은 결론이 원칙 — 다르면 이유를 메모 |
|
| **등록 권리 대상 일치 0.95** | 기준 DB 에서 운영자가 **확정**한 이미지(주의 후보 승격, 수동 등록)와 같은 이미지 | 단독으로 높음. 이전 판정과 같은 결론이 원칙 — 다르면 이유를 메모 |
|
||||||
|
| **주의 후보 일치 0.95** | 보류·반려로 자동 생성된, 아직 확정 안 된 항목과 같은 이미지 | 단독으로 중간 — "누가 다시 보려 했다"는 뜻. 기준 DB 에서 그 항목의 메모를 열어 이전 판정을 확인하고, 반복되면 `승격` 으로 확정한다 |
|
||||||
| **웹 완전일치 사본 N곳** | 검색 결과·구글 이미지 감지에서 **같은 이미지**가 N개 호스트에서 발견 | 가장 중요한 사실. 출처가 언론사·공식 계정이면 그쪽이 원저작자일 가능성이 높다 |
|
| **웹 완전일치 사본 N곳** | 검색 결과·구글 이미지 감지에서 **같은 이미지**가 N개 호스트에서 발견 | 가장 중요한 사실. 출처가 언론사·공식 계정이면 그쪽이 원저작자일 가능성이 높다 |
|
||||||
| 웹 부분·페이지 일치 / 웹 유사 이미지 | 잘렸거나 편집된 사본 / 비슷한 구도 | 참고. 부분 일치는 열어 보고, 유사는 앞의 둘이 없을 때만 |
|
| 웹 부분·페이지 일치 / 웹 유사 이미지 | 잘렸거나 편집된 사본 / 비슷한 구도 | 참고. 부분 일치는 열어 보고, 유사는 앞의 둘이 없을 때만 |
|
||||||
| **식별 가능 인물** / 배경 인물 | 얼굴이 알아볼 수 있게 찍힘 / 군중 속 인물 | 초상권 축. 유명인·특정 개인이면 사본이 없어도 보류 검토 |
|
| **식별 가능 인물** / 배경 인물 | 얼굴이 알아볼 수 있게 찍힘 / 군중 속 인물 | 초상권 축. 유명인·특정 개인이면 사본이 없어도 보류 검토 |
|
||||||
|
|
@ -41,7 +42,7 @@
|
||||||
| 판정 | 언제 | 뒤에 일어나는 일 |
|
| 판정 | 언제 | 뒤에 일어나는 일 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **승인** `A` | 자사 제작·라이선스 확인·자유 이용 확인. 웹 사본이 있어도 출처가 우리이거나 계약처면 승인 | 지원자 상태 '승인 대기'. 기준 DB 에는 **아무것도 안 들어간다** — 재사용될 이미지면 `클리어 기준 등록` 을 따로 누른다 |
|
| **승인** `A` | 자사 제작·라이선스 확인·자유 이용 확인. 웹 사본이 있어도 출처가 우리이거나 계약처면 승인 | 지원자 상태 '승인 대기'. 기준 DB 에는 **아무것도 안 들어간다** — 재사용될 이미지면 `클리어 기준 등록` 을 따로 누른다 |
|
||||||
| **보류** `H` | 출처 불명·권리자 확인 필요·검색이 안 돈 경우 | 기준 DB 에 **주의 후보(watchlist)** 로 들어간다. 같은 이미지가 다시 오면 `주의 후보 이미지 유사도` 로 잡힌다 |
|
| **보류** `H` | 출처 불명·권리자 확인 필요·검색이 안 돈 경우 | 기준 DB 에 **주의 후보(watchlist)** 로 들어간다. 같은 이미지가 다시 오면 `주의 후보 이미지 유사도` 로 잡힌다. 단, **검색이 안 돌아서(미수행·실패) 보류한 경우는 주의 후보를 만들지 않는다** — "확인 못 함"은 "의심됨"이 아니다. 감사 로그에 `검색 대기` 로 남는다 |
|
||||||
| **반려** `R` | 타인 저작물 사본·초상권 침해·이전 반려와 동일 | 사유 필수. 기준 DB 주의 후보 갱신. 지원자 상태 '반려 예정' |
|
| **반려** `R` | 타인 저작물 사본·초상권 침해·이전 반려와 동일 | 사유 필수. 기준 DB 주의 후보 갱신. 지원자 상태 '반려 예정' |
|
||||||
|
|
||||||
**보류를 아끼지 말 것.** 판정을 미루는 것이 아니라 "다음에 같은 이미지가 오면 잡아라"를
|
**보류를 아끼지 말 것.** 판정을 미루는 것이 아니라 "다음에 같은 이미지가 오면 잡아라"를
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ from rights_filter.domain.records import Evidence, EvidenceSource, ScoreResult
|
||||||
# 그 위에 쌓였다. 캡 없는 두 축만 캡해도(80/50, 60/40 시뮬레이션) 20/20 이 그대로
|
# 그 위에 쌓였다. 캡 없는 두 축만 캡해도(80/50, 60/40 시뮬레이션) 20/20 이 그대로
|
||||||
# 100 이었다 - 축 하나를 손보는 것으로는 안 되고 예산을 총합 기준으로 다시 짜야 한다.
|
# 100 이었다 - 축 하나를 손보는 것으로는 안 되고 예산을 총합 기준으로 다시 짜야 한다.
|
||||||
#
|
#
|
||||||
# 등록 권리 대상 일치 80 운영자가 지식DB 에 등록한 판단. 단독 high.
|
# 등록 권리 대상 일치 80 운영자가 지식DB 에 등록·확정한 판단. 단독 high.
|
||||||
|
# 주의 후보 일치 40 보류·반려로 자동 생성된 미확정 항목. 단독 medium. (v7)
|
||||||
# 웹 존재 40 사본 · Vision 매치 · CSE 페이지가 한 풀. 단독 medium.
|
# 웹 존재 40 사본 · Vision 매치 · CSE 페이지가 한 풀. 단독 medium.
|
||||||
# 인물 30 식별 가능 30 / 배경 10
|
# 인물 30 식별 가능 30 / 배경 10
|
||||||
# 이름 있는 검색 승격 15 네이버 텍스트 검색. 단독으로는 밴드 못 만든다.
|
# 이름 있는 검색 승격 15 네이버 텍스트 검색. 단독으로는 밴드 못 만든다.
|
||||||
|
|
@ -53,6 +54,14 @@ KNOWLEDGE_MATCH_MAX_POINTS = 80
|
||||||
KNOWLEDGE_MATCH_STRONG_POINTS = 80 # 유사도 0.9 이상
|
KNOWLEDGE_MATCH_STRONG_POINTS = 80 # 유사도 0.9 이상
|
||||||
KNOWLEDGE_MATCH_WEAK_POINTS = 30
|
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_PRESENCE_MAX_POINTS = 40
|
||||||
WEB_COPY_EXACT_POINTS = 40 # 검색결과 사본 유사도 0.99 이상 · Vision full
|
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_NEAR_POINTS = 30 # 유사도 0.9~0.99 · Vision partial/page · CSE page · 레거시 url 행
|
||||||
|
|
@ -90,12 +99,16 @@ FAILURE_FLOOR_POINTS = 30
|
||||||
# failure is a 30-point floor instead of +30 per row.
|
# 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
|
# 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.
|
# failure floor and says so; before v6 it scored zero with no reason line.
|
||||||
SCORER_VERSION = "v6"
|
# 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 = {
|
_COMPONENT_LABELS = {
|
||||||
"knowledge": "등록 권리 대상 일치",
|
"knowledge": "등록 권리 대상 일치",
|
||||||
|
"watchlist": "주의 후보 일치 (보류 · 반려 이력)",
|
||||||
"web_presence": "웹 존재 (사본 · 매치)",
|
"web_presence": "웹 존재 (사본 · 매치)",
|
||||||
"face": "인물",
|
"face": "인물",
|
||||||
"naver": "이름 있는 검색 승격",
|
"naver": "이름 있는 검색 승격",
|
||||||
|
|
@ -104,6 +117,7 @@ _COMPONENT_LABELS = {
|
||||||
}
|
}
|
||||||
_POOL_CAPS = {
|
_POOL_CAPS = {
|
||||||
"knowledge": KNOWLEDGE_MATCH_MAX_POINTS,
|
"knowledge": KNOWLEDGE_MATCH_MAX_POINTS,
|
||||||
|
"watchlist": WATCHLIST_MATCH_MAX_POINTS,
|
||||||
"web_presence": WEB_PRESENCE_MAX_POINTS,
|
"web_presence": WEB_PRESENCE_MAX_POINTS,
|
||||||
"naver": NAVER_MAX_POINTS,
|
"naver": NAVER_MAX_POINTS,
|
||||||
"web_entity": WEB_ENTITY_MAX_POINTS,
|
"web_entity": WEB_ENTITY_MAX_POINTS,
|
||||||
|
|
@ -187,6 +201,17 @@ class RiskScorer:
|
||||||
copy_points = _search_result_copy_points(similarity)
|
copy_points = _search_result_copy_points(similarity)
|
||||||
pool("web_presence", copy_points)
|
pool("web_presence", copy_points)
|
||||||
tally.presence[_presence_tier(copy_points)].add(_presence_key(item))
|
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:
|
elif similarity >= 0.9:
|
||||||
# A match against an operator-registered rights subject. Not a
|
# A match against an operator-registered rights subject. Not a
|
||||||
# fact about the web but a judgement the team recorded, so it
|
# fact about the web but a judgement the team recorded, so it
|
||||||
|
|
@ -300,7 +325,7 @@ class RiskScorer:
|
||||||
# the operator reads the panel in the same order the comment explains.
|
# the operator reads the panel in the same order the comment explains.
|
||||||
components: list[dict[str, Any]] = []
|
components: list[dict[str, Any]] = []
|
||||||
score = 0
|
score = 0
|
||||||
for key in ("knowledge", "web_presence", "face", "naver", "web_entity"):
|
for key in ("knowledge", "watchlist", "web_presence", "face", "naver", "web_entity"):
|
||||||
if key == "face":
|
if key == "face":
|
||||||
if face_points:
|
if face_points:
|
||||||
score += face_points
|
score += face_points
|
||||||
|
|
@ -355,6 +380,7 @@ class _Tally:
|
||||||
|
|
||||||
knowledge_best: float = 0.0
|
knowledge_best: float = 0.0
|
||||||
knowledge_weak: bool = False
|
knowledge_weak: bool = False
|
||||||
|
watchlist_best: float = 0.0
|
||||||
cleared: bool = False
|
cleared: bool = False
|
||||||
presence: dict[str, set[str]] = field(default_factory=lambda: {"exact": set(), "near": set(), "weak": set()})
|
presence: dict[str, set[str]] = field(default_factory=lambda: {"exact": set(), "near": set(), "weak": set()})
|
||||||
face: str = ""
|
face: str = ""
|
||||||
|
|
@ -396,6 +422,8 @@ def _headline(tally: _Tally, excluded_rows: int) -> str:
|
||||||
parts.append(f"등록 권리 대상 일치 {tally.knowledge_best:.2f}")
|
parts.append(f"등록 권리 대상 일치 {tally.knowledge_best:.2f}")
|
||||||
elif tally.knowledge_weak:
|
elif tally.knowledge_weak:
|
||||||
parts.append("등록 권리 대상 약한 일치")
|
parts.append("등록 권리 대상 약한 일치")
|
||||||
|
if tally.watchlist_best:
|
||||||
|
parts.append(f"주의 후보 일치 {tally.watchlist_best:.2f}")
|
||||||
if tally.cleared:
|
if tally.cleared:
|
||||||
parts.append("클리어 이력 일치")
|
parts.append("클리어 이력 일치")
|
||||||
if tally.presence["exact"]:
|
if tally.presence["exact"]:
|
||||||
|
|
@ -447,6 +475,26 @@ def _is_transient_skip(item: Evidence) -> bool:
|
||||||
return any(marker in reason for marker in _TRANSIENT_SKIP_MARKERS)
|
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:
|
def _skip_provider(item: Evidence) -> str:
|
||||||
# Rows restored from persistence lose `provider` but keep `domain`, which held
|
# Rows restored from persistence lose `provider` but keep `domain`, which held
|
||||||
# the provider name ("internal" when there was none) - the round trip has to
|
# the provider name ("internal" when there was none) - the round trip has to
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,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,
|
||||||
|
|
@ -56,6 +56,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,
|
||||||
|
|
@ -795,14 +796,25 @@ 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)
|
||||||
|
|
||||||
self.add_audit_event(
|
self.add_audit_event(
|
||||||
actor="rights.ops",
|
actor="rights.ops",
|
||||||
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'}"
|
||||||
|
+ (" · 검색 대기 (주의 후보 미생성)" if waiting_for_search else "")
|
||||||
|
),
|
||||||
conn=conn,
|
conn=conn,
|
||||||
)
|
)
|
||||||
return self.review(submission_id)
|
return self.review(submission_id)
|
||||||
|
|
|
||||||
|
|
@ -807,6 +807,56 @@ def test_promoted_hits_from_distinct_queries_pool_under_one_ceiling():
|
||||||
assert len(result.reasons) == 3
|
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초에 읽는 한 줄 ---------------------------------------
|
# --- headline: 운영자가 첫 1초에 읽는 한 줄 ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -855,6 +905,11 @@ def test_headline_leads_with_a_registered_match_and_ends_with_the_gaps():
|
||||||
assert _headline_of(evidence) == "등록 권리 대상 일치 0.95 · 배경 인물 · 외부 검색 실패 · 일일 한도로 일부 미수행"
|
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_reports_trusted_exclusions_and_silence():
|
def test_headline_reports_trusted_exclusions_and_silence():
|
||||||
assert _headline_of([_published_on("posar.info")], frozenset({"posar.info"})) == "신뢰 출처 제외 1행"
|
assert _headline_of([_published_on("posar.info")], frozenset({"posar.info"})) == "신뢰 출처 제외 1행"
|
||||||
assert _headline_of([]) == "신호 없음"
|
assert _headline_of([]) == "신호 없음"
|
||||||
|
|
|
||||||
|
|
@ -790,6 +790,50 @@ 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_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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue