From 92fc8094d408b1b2d051aaed7c069c0465ba81ab Mon Sep 17 00:00:00 2001 From: changukyu Date: Wed, 23 Sep 2026 21:45:02 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=8A=A4=EC=BD=94=EC=96=B4=EB=9F=AC=20?= =?UTF-8?q?v7=20+=20=EA=B2=80=EC=83=89=20=EB=8C=80=EA=B8=B0=20=EB=B3=B4?= =?UTF-8?q?=EB=A5=98=20=E2=80=94=20=EC=A3=BC=EC=9D=98=20=ED=9B=84=EB=B3=B4?= =?UTF-8?q?=20=EC=9D=BC=EC=B9=98=EB=8A=94=20=EB=8B=A4=EC=8B=9C=20=EB=B3=B4?= =?UTF-8?q?=EB=9D=BC=EB=8A=94=20=EB=9C=BB=EC=9D=B4=EC=A7=80=20=EC=95=8C?= =?UTF-8?q?=EB=A0=A4=EC=A7=84=20=EA=B6=8C=EB=A6=AC=20=EB=8C=80=EC=83=81?= =?UTF-8?q?=EC=9D=B4=20=EC=95=84=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V --- docs/INDEX.md | 2 + docs/PROJECT_MAP.md | 2 +- docs/diagrams/evidence-dataflow.dataflow.json | 4 +- docs/diagrams/evidence-dataflow.html | Bin 823181 -> 823181 bytes docs/diagrams/manifest.json | 12 ++-- docs/diagrams/operator-review-workflow.html | Bin 818466 -> 818466 bytes .../operator-review-workflow.workflow.json | 2 +- .../system-architecture.architecture.json | 2 +- docs/diagrams/system-architecture.html | Bin 816524 -> 816524 bytes docs/operations/review-guide.md | 5 +- src/rights_filter/analysis/risk_scoring.py | 54 ++++++++++++++++- src/rights_filter/server/sqlite_store.py | 18 +++++- .../analysis/test_risk_scoring.py | 55 ++++++++++++++++++ .../rights_filter/server/test_sqlite_store.py | 44 ++++++++++++++ 14 files changed, 181 insertions(+), 19 deletions(-) diff --git a/docs/INDEX.md b/docs/INDEX.md index 03a33eb..049584a 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,5 +1,7 @@ # 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 · **심사 가이드 다이어그램** — `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 표 추가. diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index e7b9908..78f4e44 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -41,7 +41,7 @@ python run_copyrighter_server.py # 기본 127.0.0.1:9500 | `fingerprints.py` | 이미지 지문. `_perceptual_hash`는 **DCT pHash가 아니라 8x8 평균해시(aHash)** — 이름과 실제가 다름(알려진 부채) | | `face_person_detection.py` | YuNet(ONNX) 얼굴 검출 + 인물 등급(`identifiable`/`background`). 가중치는 `models/`(gitignore) | | `internal_analyzer.py` | 내부 분석 오케스트레이션 | -| `risk_scoring.py` | `RiskScorer.score()` — 점수·밴드 산출. **`SCORER_VERSION` 은 가중치/버킷/밴드 변경 시 반드시 올린다**. `RiskScorer(trusted_hosts)` 는 지식DB 의 활성 `trusted_host` 항목(이름·별칭이 도메인)을 받아 그 호스트에서 발견된 웹 탐지 행을 0점 처리한다 — **행 단위 제외**라 자사 사본과 스크래퍼 게시가 같이 있으면 후자는 그대로 센다. v5/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` | 증거 정규화·보강 | | `search_query_generation.py`, `search_result_promoter.py` | 자동 검색 쿼리 생성, 검색 결과의 증거 승격 | | `llm_assistance.py`, `llm_prompts.py` | LLM을 **요약기가 아닌 증거 판독기**로 사용. 입력 화이트리스트 9필드 + 상위 12건 상한, `format=json` 구조화 인용, 라벨 집합 포함 검사로 미인용 claim 폐기 | diff --git a/docs/diagrams/evidence-dataflow.dataflow.json b/docs/diagrams/evidence-dataflow.dataflow.json index 292ca03..658257b 100644 --- a/docs/diagrams/evidence-dataflow.dataflow.json +++ b/docs/diagrams/evidence-dataflow.dataflow.json @@ -140,7 +140,7 @@ "type": "backend", "label": "RiskScorer", "sublabel": "한 풀 · 제공자별 버킷", - "tag": "v6", + "tag": "v7", "stage": 2, "row": 1 }, @@ -294,7 +294,7 @@ "title": "주 경로", "items": [ "외부 응답은 데이터 등급 검사를 통과한 필드만 Evidence 가 된다", - "RiskScorer v6 는 웹 존재를 제공자 무관 한 풀로 센다 (presence 40 / entity 10)", + "RiskScorer v7 는 웹 존재를 제공자 무관 한 풀로 센다 (presence 40 / entity 10)", "점수 · 밴드 · 사유는 payload 로 직렬화되어 evidence 테이블에 남는다", "신뢰 출처(자사·계약처 도메인)에서 발견된 행은 0점이다 — 행 단위 제외라 스크래퍼 게시는 그대로 센다" ] diff --git a/docs/diagrams/evidence-dataflow.html b/docs/diagrams/evidence-dataflow.html index 7c6013d0426a23d619ba692f62d6a0e8c8a2bc99..1a55fa756b58dfb88cd181d0a6e0152ff7b0e093 100644 GIT binary patch delta 89 zcmeC3Z`3>AsG)_ig{g(Pg=Gut3ROn)>52BNV$=7lvQ_}u5A9iHwuh*(axsG>AsG)_ig{g(Pg=Gut3ROn4>52BNV$=7lvQ_}u5A9iHwuh*(axsG>F7M3lny^@UP)8kE8#isKJu*yt7AjvulD6r59$UbGt$~FD6 x6szv^^`@+<+kvb-OhCCrT~@93U9zk|%m&2lK+FNeoIuP4#N69=$?{~x0{}<7BHaK0 delta 105 zcmZ4V%W%;z!-f{d7N!>F7M3lny^@S()8kE8#isKJu*yt7AjvulD6r59$UbGt$~FD6 x6szv^^`@+<+kvb-OhCCrT~@93U9zk|%m&2lK+FNeoIuP4#N69=$?{~x0{}+*BH91| diff --git a/docs/diagrams/operator-review-workflow.workflow.json b/docs/diagrams/operator-review-workflow.workflow.json index 1c0fa80..2f6146e 100644 --- a/docs/diagrams/operator-review-workflow.workflow.json +++ b/docs/diagrams/operator-review-workflow.workflow.json @@ -175,7 +175,7 @@ "type": "backend", "label": "위험도 산출", "sublabel": "점수 · 밴드 · 사유", - "tag": "v6", + "tag": "v7", "width": 124 }, { diff --git a/docs/diagrams/system-architecture.architecture.json b/docs/diagrams/system-architecture.architecture.json index 1c57fab..1d84010 100644 --- a/docs/diagrams/system-architecture.architecture.json +++ b/docs/diagrams/system-architecture.architecture.json @@ -140,7 +140,7 @@ "type": "backend", "label": "분석 · 채점", "sublabel": "지문 · 얼굴 · 출처", - "tag": "RiskScorer v6", + "tag": "RiskScorer v7", "pos": [ 678, 60 diff --git a/docs/diagrams/system-architecture.html b/docs/diagrams/system-architecture.html index ab732fa93403c6521779fa90abd9c51592e2ab0f..8a7aa1751ce42de516681c492f27d801bf04285e 100644 GIT binary patch delta 85 zcmeBqZP@eLu%U&qg{g(Pg=Gt?qAa8N^!#5e#nY$Cvi1Pk8yQ(;w%f_E3NiyZ2hCaK e+w&D!ftU@5*@2h?h&h3n3y8V5=PUBah5!JrNEyKZ delta 85 zcmeBqZP@eLu%U&qg{g(Pg=Gt?qAa7?^!#5e#nY$Cvi1Pk8yQ(;w%f_E3NiyZ2hCaK e+w&D!ftU@5*@2h?h&h3n3y8V5=PUBah5!Jqsu{ij diff --git a/docs/operations/review-guide.md b/docs/operations/review-guide.md index 2c2efb3..29e80e6 100644 --- a/docs/operations/review-guide.md +++ b/docs/operations/review-guide.md @@ -23,7 +23,8 @@ | 한 줄 요약에 나오는 말 | 뜻 | 판정에서의 무게 | |---|---|---| -| **등록 권리 대상 일치 0.95** | 기준 DB 에 등록된 이미지(반려·보류 이력, 수동 등록)와 같은 이미지 | 단독으로 높음. 이전 판정과 같은 결론이 원칙 — 다르면 이유를 메모 | +| **등록 권리 대상 일치 0.95** | 기준 DB 에서 운영자가 **확정**한 이미지(주의 후보 승격, 수동 등록)와 같은 이미지 | 단독으로 높음. 이전 판정과 같은 결론이 원칙 — 다르면 이유를 메모 | +| **주의 후보 일치 0.95** | 보류·반려로 자동 생성된, 아직 확정 안 된 항목과 같은 이미지 | 단독으로 중간 — "누가 다시 보려 했다"는 뜻. 기준 DB 에서 그 항목의 메모를 열어 이전 판정을 확인하고, 반복되면 `승격` 으로 확정한다 | | **웹 완전일치 사본 N곳** | 검색 결과·구글 이미지 감지에서 **같은 이미지**가 N개 호스트에서 발견 | 가장 중요한 사실. 출처가 언론사·공식 계정이면 그쪽이 원저작자일 가능성이 높다 | | 웹 부분·페이지 일치 / 웹 유사 이미지 | 잘렸거나 편집된 사본 / 비슷한 구도 | 참고. 부분 일치는 열어 보고, 유사는 앞의 둘이 없을 때만 | | **식별 가능 인물** / 배경 인물 | 얼굴이 알아볼 수 있게 찍힘 / 군중 속 인물 | 초상권 축. 유명인·특정 개인이면 사본이 없어도 보류 검토 | @@ -41,7 +42,7 @@ | 판정 | 언제 | 뒤에 일어나는 일 | |---|---|---| | **승인** `A` | 자사 제작·라이선스 확인·자유 이용 확인. 웹 사본이 있어도 출처가 우리이거나 계약처면 승인 | 지원자 상태 '승인 대기'. 기준 DB 에는 **아무것도 안 들어간다** — 재사용될 이미지면 `클리어 기준 등록` 을 따로 누른다 | -| **보류** `H` | 출처 불명·권리자 확인 필요·검색이 안 돈 경우 | 기준 DB 에 **주의 후보(watchlist)** 로 들어간다. 같은 이미지가 다시 오면 `주의 후보 이미지 유사도` 로 잡힌다 | +| **보류** `H` | 출처 불명·권리자 확인 필요·검색이 안 돈 경우 | 기준 DB 에 **주의 후보(watchlist)** 로 들어간다. 같은 이미지가 다시 오면 `주의 후보 이미지 유사도` 로 잡힌다. 단, **검색이 안 돌아서(미수행·실패) 보류한 경우는 주의 후보를 만들지 않는다** — "확인 못 함"은 "의심됨"이 아니다. 감사 로그에 `검색 대기` 로 남는다 | | **반려** `R` | 타인 저작물 사본·초상권 침해·이전 반려와 동일 | 사유 필수. 기준 DB 주의 후보 갱신. 지원자 상태 '반려 예정' | **보류를 아끼지 말 것.** 판정을 미루는 것이 아니라 "다음에 같은 이미지가 오면 잡아라"를 diff --git a/src/rights_filter/analysis/risk_scoring.py b/src/rights_filter/analysis/risk_scoring.py index 982e7ec..b54642d 100644 --- a/src/rights_filter/analysis/risk_scoring.py +++ b/src/rights_filter/analysis/risk_scoring.py @@ -17,7 +17,8 @@ from rights_filter.domain.records import Evidence, EvidenceSource, ScoreResult # 그 위에 쌓였다. 캡 없는 두 축만 캡해도(80/50, 60/40 시뮬레이션) 20/20 이 그대로 # 100 이었다 - 축 하나를 손보는 것으로는 안 되고 예산을 총합 기준으로 다시 짜야 한다. # -# 등록 권리 대상 일치 80 운영자가 지식DB 에 등록한 판단. 단독 high. +# 등록 권리 대상 일치 80 운영자가 지식DB 에 등록·확정한 판단. 단독 high. +# 주의 후보 일치 40 보류·반려로 자동 생성된 미확정 항목. 단독 medium. (v7) # 웹 존재 40 사본 · Vision 매치 · CSE 페이지가 한 풀. 단독 medium. # 인물 30 식별 가능 30 / 배경 10 # 이름 있는 검색 승격 15 네이버 텍스트 검색. 단독으로는 밴드 못 만든다. @@ -53,6 +54,14 @@ 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 행 @@ -90,12 +99,16 @@ FAILURE_FLOOR_POINTS = 30 # 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. -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 = { "knowledge": "등록 권리 대상 일치", + "watchlist": "주의 후보 일치 (보류 · 반려 이력)", "web_presence": "웹 존재 (사본 · 매치)", "face": "인물", "naver": "이름 있는 검색 승격", @@ -104,6 +117,7 @@ _COMPONENT_LABELS = { } _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, @@ -187,6 +201,17 @@ class RiskScorer: 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 @@ -300,7 +325,7 @@ class RiskScorer: # the operator reads the panel in the same order the comment explains. components: list[dict[str, Any]] = [] 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 face_points: score += face_points @@ -355,6 +380,7 @@ class _Tally: 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 = "" @@ -396,6 +422,8 @@ def _headline(tally: _Tally, excluded_rows: int) -> str: 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("클리어 이력 일치") 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) +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 diff --git a/src/rights_filter/server/sqlite_store.py b/src/rights_filter/server/sqlite_store.py index 3f3c57a..881dff5 100644 --- a/src/rights_filter/server/sqlite_store.py +++ b/src/rights_filter/server/sqlite_store.py @@ -13,7 +13,7 @@ from typing import Any, Callable from rights_filter.analysis.face_person_detection import HeuristicFacePersonDetector from rights_filter.analysis.fingerprints import FingerprintService 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 ( CloudVisionWebDetectionAdapter, FakeWebDetectionClient, @@ -56,6 +56,7 @@ from rights_filter.server.store_schema import ( ) from rights_filter.server.store_serialization import ( _default_evidence_contribution, + _domain_evidence_from_ui, _evidence_id, _evidence_matches_provider, _evidence_payload, @@ -795,14 +796,25 @@ class CopyrighterStore( submission["decisionSnapshot"] = snapshot 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.add_audit_event( actor="rights.ops", event="Operator decision created", 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, ) return self.review(submission_id) diff --git a/tests/rights_filter/analysis/test_risk_scoring.py b/tests/rights_filter/analysis/test_risk_scoring.py index 144f31a..634d802 100644 --- a/tests/rights_filter/analysis/test_risk_scoring.py +++ b/tests/rights_filter/analysis/test_risk_scoring.py @@ -807,6 +807,56 @@ def test_promoted_hits_from_distinct_queries_pool_under_one_ceiling(): 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초에 읽는 한 줄 --------------------------------------- @@ -855,6 +905,11 @@ def test_headline_leads_with_a_registered_match_and_ends_with_the_gaps(): 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(): assert _headline_of([_published_on("posar.info")], frozenset({"posar.info"})) == "신뢰 출처 제외 1행" assert _headline_of([]) == "신호 없음" diff --git a/tests/rights_filter/server/test_sqlite_store.py b/tests/rights_filter/server/test_sqlite_store.py index 845b8d0..0097c14 100644 --- a/tests/rights_filter/server/test_sqlite_store.py +++ b/tests/rights_filter/server/test_sqlite_store.py @@ -790,6 +790,50 @@ def test_sqlite_store_watchlist_similarity_is_high_risk_but_separate_from_confir 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): image_store = _image_store(tmp_path / "submissions") db_path = tmp_path / "copyrighter.sqlite3"