feat: 스코어러 v6 — 외부 검색 미수행(일일 한도)의 조용한 통과를 막는다

배포 판단에서 파일럿 전 유일한 차단으로 꼽은 구멍. 네이버 일일 한도 100 = 20장이라
21장째부터 검색이 SEARCH_SKIPPED 0점·무사유로 빠져 점수가 낮게 나왔다.

정책 거절 사유 중 하루 안에서 케이스마다 달라지고 어디에도 안 보이는 하나(usage limit
reached)만 v5 의 실패 하한 30 에 합류시키고 사유에 '<provider> 미수행 — <사유>' 를 남긴다.
disabled 는 키 미설정 배포와 비상 차단이 같은 사유를 쓰고 둘 다 콘솔 providerState 에
보이므로 그대로 0점·무사유 — 하한을 걸면 키 없는 배포의 큐 전체가 medium 이 된다(기본
스토어 테스트가 잡아냈다). 거버넌스 스위치(compliance·metadata logging·online mode)도 0.

라운드트립은 신선 점수·사유를 재현한다(provider 는 domain 으로 복원). 다이어그램 태그 v6.

verify.ps1: ruff clean, 657 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
This commit is contained in:
changukyu 2026-09-22 11:06:58 +09:00
parent 6017750bae
commit 4b74f9577a
13 changed files with 121 additions and 40 deletions

View file

@ -1,5 +1,7 @@
# Project INDEX
- 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 에 추가(저작권 이미지 커밋 방지)

View file

@ -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 축 예산표(등록 일치 80 · 웹 존재 40 한 풀 · 인물 30/10 · 검색 승격 15 · 라벨 10 · 실패는 하한 30)는 파일 상단 주석이 정본 — 검색결과 사본 행은 `knowledge_entry_id` 부재 + `url`/`source_evidence_ids` 로 웹 풀에 간다 |
| `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` 로 웹 풀에 간다 |
| `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 폐기 |

View file

@ -140,7 +140,7 @@
"type": "backend",
"label": "RiskScorer",
"sublabel": "한 풀 · 제공자별 버킷",
"tag": "v5",
"tag": "v6",
"stage": 2,
"row": 1
},
@ -294,7 +294,7 @@
"title": "주 경로",
"items": [
"외부 응답은 데이터 등급 검사를 통과한 필드만 Evidence 가 된다",
"RiskScorer v5 는 웹 존재를 제공자 무관 한 풀로 센다 (presence 40 / entity 10)",
"RiskScorer v6 는 웹 존재를 제공자 무관 한 풀로 센다 (presence 40 / entity 10)",
"점수 · 밴드 · 사유는 payload 로 직렬화되어 evidence 테이블에 남는다",
"신뢰 출처(자사·계약처 도메인)에서 발견된 행은 0점이다 — 행 단위 제외라 스크래퍼 게시는 그대로 센다"
]

Binary file not shown.

View file

@ -6,24 +6,24 @@
"type": "architecture",
"spec": "system-architecture.architecture.json",
"html": "system-architecture.html",
"specSha256": "74b2864bb11457a61f8956a8556d1f24e0e2e0bf8356efa350076bb9f7a415df",
"htmlSha256": "ec9c460515cf0d29d9c0968ee052d09e9eb7a3292ff893cf2a9ce6ecf9d2b05a",
"specSha256": "48900a6970d64c978163dac06894150b3ff9636eca84efcbda999bf46deb2750",
"htmlSha256": "f09a4482dc03332dfb959853ef64ff2ba723891e223a4906cff8719794469d17",
"htmlBytes": 816524
},
{
"type": "dataflow",
"spec": "evidence-dataflow.dataflow.json",
"html": "evidence-dataflow.html",
"specSha256": "f11b2132d947720bc6558cd14c201df48135d4458673910a050f9b6f11e0ab60",
"htmlSha256": "457f6757fd3f7a755bb769fa6a2e8f34d5e12b1a6fcacbd6a6e733e176980b03",
"specSha256": "88c1f67a6b34d1a31ed15a730f84fb6c474deab1ef81a582b87f30ec3157f0a3",
"htmlSha256": "7b5f80dd87bbe9bee99aef1a311312bfd5d79d4508a2226a3af0329cdb621e79",
"htmlBytes": 823181
},
{
"type": "workflow",
"spec": "operator-review-workflow.workflow.json",
"html": "operator-review-workflow.html",
"specSha256": "00a2723fb6afa8a0a4f9fc4110267d6ddc0c48dd9bbe0d132a58b4d00424d7d4",
"htmlSha256": "e49518ac5d2a98d4967b809c62e24fc429773be912ed7dd7f445c7d20467b1bd",
"specSha256": "d9158494d38c48b3a7ddc4cd55b8a2c6576d39ff7a94c9be25465eabbca41d39",
"htmlSha256": "e51d09c4251fb762c63142e870d6b58aef7d8f5ca6857875248dc9ee56ae0e7f",
"htmlBytes": 818466
},
{

Binary file not shown.

View file

@ -175,7 +175,7 @@
"type": "backend",
"label": "위험도 산출",
"sublabel": "점수 · 밴드 · 사유",
"tag": "v5",
"tag": "v6",
"width": 124
},
{

View file

@ -140,7 +140,7 @@
"type": "backend",
"label": "분석 · 채점",
"sublabel": "지문 · 얼굴 · 출처",
"tag": "RiskScorer v5",
"tag": "RiskScorer v6",
"pos": [
678,
60

Binary file not shown.

View file

@ -34,9 +34,10 @@
## 2. 준비 — 안 하면 코퍼스가 오염된다
**네이버 일일 한도.** `COPYRIGHTER_NAVER_DAILY_LIMIT=100` 인데 자동 검색이 장당 최대
5콜(이미지 3 + 블로그 1 + 웹 1)이라 20장째에 고갈된다. 고갈분은 `SEARCH_SKIPPED`
**0점으로 조용히** 처리되므로(`risk_scoring.py` 가 skipped 를 운영 상태로 보고 건너뛴다)
앞쪽과 뒤쪽 이미지의 증거 밀도가 달라진다.
5콜(이미지 3 + 블로그 1 + 웹 1)이라 20장째에 고갈된다. 고갈분은 v6 부터 검토 하한
30/medium 으로 올라가고 사유에 `naver 미수행 — search API usage limit reached` 가 남는다
(v5 까지는 0점·무사유로 조용히 지나갔다). 보이게는 됐지만 앞쪽과 뒤쪽 이미지의 증거
밀도가 다른 것은 그대로라 코퍼스 런에서는 한도를 올린다.
```
.env: COPYRIGHTER_NAVER_DAILY_LIMIT=400

View file

@ -23,7 +23,7 @@ from rights_filter.domain.records import Evidence, EvidenceSource, ScoreResult
# 라벨 10 Vision 엔티티. 단독으로는 밴드 못 만든다.
# -------------------------
# 등록 제외 최대 95 → 100 에 닿지 않아 웹에 있는 이미지들 사이에 순서가 남는다.
# 외부 검색 실패 하한 30 점수가 아니라 바닥. 아래 FAILURE_FLOOR_POINTS 참조.
# 외부 검색 미수행·실패 하한 30 점수가 아니라 바닥. 아래 FAILURE_FLOOR_POINTS 참조.
#
# 흔한 케이스(웹 사본 + 식별 가능 인물)가 정확히 70 으로 high 문턱에 서고, 그 위로
# 이름 있는 검색·라벨이, 아래로 근사 사본·배경 인물이 순서를 만든다. 웹 신호만으로는
@ -63,9 +63,11 @@ NAVER_MAX_POINTS = 15
WEB_ENTITY_LABEL_POINTS = 10 # 라벨당 round(10 * confidence)
WEB_ENTITY_MAX_POINTS = 10
# 외부 검색 실패는 증거가 아니라 커버리지 공백이다. "확인 못 했다"는 케이스는 반드시
# 사람이 봐야 하므로 medium 하한까지 끌어올리되, 이미 그 위에 있는 케이스에는 아무것도
# 더하지 않는다 - 더하면 네이버 장애 하루가 웹에 있는 모든 이미지를 high 로 밀어 올린다.
# 외부 검색 실패·미수행은 증거가 아니라 커버리지 공백이다. "확인 못 했다"는 케이스는
# 반드시 사람이 봐야 하므로 medium 하한까지 끌어올리되, 이미 그 위에 있는 케이스에는
# 아무것도 더하지 않는다 - 더하면 네이버 장애 하루가 웹에 있는 모든 이미지를 high 로
# 밀어 올린다. 미수행(일일 한도 도달)도 같은 공백이다: v5 까지는 0점·무사유라 하루
# 21장째부터 검색이 조용히 빠졌고 점수는 낮게 나왔다.
FAILURE_FLOOR_POINTS = 30
# Stamped onto every submission at creation so a later weight change is
@ -85,7 +87,9 @@ FAILURE_FLOOR_POINTS = 30
# 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.
SCORER_VERSION = "v5"
# 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"
# 점수 분해에 쓰는 사람이 읽는 이름. 키는 코드가, 라벨은 운영자가 본다. 순서는 위 예산표.
@ -95,7 +99,7 @@ _COMPONENT_LABELS = {
"face": "인물",
"naver": "이름 있는 검색 승격",
"web_entity": "웹 엔티티 라벨",
"failure": "외부 검색 실패 (검토 하한)",
"failure": "외부 검색 미수행 · 실패 (검토 하한)",
}
_POOL_CAPS = {
"knowledge": KNOWLEDGE_MATCH_MAX_POINTS,
@ -258,9 +262,20 @@ class RiskScorer:
if item.source in {EvidenceSource.FAILURE, EvidenceSource.ENRICHMENT_FAILURE}:
failure_seen = True
reasons.append(item.reason)
# SEARCH_SKIPPED / EXTERNAL_SKIPPED contribute zero points and are
# operational status ("quota reached", "provider disabled"), not
# risk signals - keep them out of the operator-facing reasons list.
elif _is_transient_skip(item):
# A provider that normally runs hit its daily quota before
# 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
reasons.append(f"{_skip_provider(item) or '외부 검색'} 미수행 — {item.reason}")
# 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
@ -330,6 +345,29 @@ def _is_search_result_copy(item: Evidence) -> bool:
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 _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

View file

@ -404,24 +404,60 @@ def test_web_presence_plus_a_second_signal_kind_reaches_high():
assert result.band == "high"
def test_search_skipped_reason_is_not_listed_as_a_risk_reason():
# 2026-07-11 #16: a zero-point skip ("quota reached") is operational status,
# not a risk signal, and must not pollute the operator-facing reasons list.
result = RiskScorer().score(
[
Evidence(
source=EvidenceSource.SEARCH_SKIPPED,
reason="search API usage limit reached",
confidence=1.0,
data={},
),
]
)
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.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,

View file

@ -659,12 +659,12 @@ def test_two_distinct_web_entities_score_after_roundtrip():
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
evidence = Evidence(
source=EvidenceSource.SEARCH_SKIPPED,
reason="Naver 검색 생략 (일일 한도)",
reason="search API usage limit reached",
confidence=0.0,
data={"provider": "naver"},
)
@ -672,8 +672,12 @@ def test_search_skipped_roundtrip_adds_no_phantom_failure_points():
assert payload["domainSource"] == "search_skipped"
restored = _roundtrip("SUB-SKIP", evidence)
assert restored.source == EvidenceSource.SEARCH_SKIPPED
# Must not be reclassified as FAILURE (+30).
assert RiskScorer().score([restored]).score == 0
# The round-trip must reproduce the fresh score: a quota skip is the review
# 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():