From 4b74f9577aca46a5d8bcb55f7b7cf625aa859989 Mon Sep 17 00:00:00 2001 From: changukyu Date: Tue, 22 Sep 2026 11:06:58 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=8A=A4=EC=BD=94=EC=96=B4=EB=9F=AC=20?= =?UTF-8?q?v6=20=E2=80=94=20=EC=99=B8=EB=B6=80=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EB=AF=B8=EC=88=98=ED=96=89(=EC=9D=BC=EC=9D=BC=20=ED=95=9C?= =?UTF-8?q?=EB=8F=84)=EC=9D=98=20=EC=A1=B0=EC=9A=A9=ED=95=9C=20=ED=86=B5?= =?UTF-8?q?=EA=B3=BC=EB=A5=BC=20=EB=A7=89=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배포 판단에서 파일럿 전 유일한 차단으로 꼽은 구멍. 네이버 일일 한도 100 = 20장이라 21장째부터 검색이 SEARCH_SKIPPED 0점·무사유로 빠져 점수가 낮게 나왔다. 정책 거절 사유 중 하루 안에서 케이스마다 달라지고 어디에도 안 보이는 하나(usage limit reached)만 v5 의 실패 하한 30 에 합류시키고 사유에 ' 미수행 — <사유>' 를 남긴다. 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 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/calibration-corpus.md | 7 +- src/rights_filter/analysis/risk_scoring.py | 56 +++++++++++++--- .../analysis/test_risk_scoring.py | 62 ++++++++++++++---- tests/rights_filter/test_review_fixes.py | 12 ++-- 13 files changed, 121 insertions(+), 40 deletions(-) diff --git a/docs/INDEX.md b/docs/INDEX.md index 576cdff..f2510f1 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,5 +1,7 @@ # Project INDEX +- 2026-09-22 · **스코어러 v6 — 외부 검색 미수행의 조용한 통과를 막는다.** 배포 판단에서 파일럿 전 유일한 차단으로 꼽은 구멍: 네이버 일일 한도 100 = 20장이라 21장째부터 검색이 `SEARCH_SKIPPED` 0점·무사유로 빠져 점수가 **낮게** 나왔다(키 만료·비상 차단도 같은 경로). 정책 거절 사유 5종 중 하루 안에서 케이스마다 달라지고 어디에도 안 보이는 하나(`usage limit reached`)만 v5 의 실패 하한 30 에 합류시키고 사유에 ` 미수행 — <사유>` 를 남긴다. `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 에 추가(저작권 이미지 커밋 방지) diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index b5fc5a2..d738f38 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 축 예산표(등록 일치 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 폐기 | diff --git a/docs/diagrams/evidence-dataflow.dataflow.json b/docs/diagrams/evidence-dataflow.dataflow.json index 2b84021..292ca03 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": "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점이다 — 행 단위 제외라 스크래퍼 게시는 그대로 센다" ] diff --git a/docs/diagrams/evidence-dataflow.html b/docs/diagrams/evidence-dataflow.html index 18a3c25001f0ec07f9b1bd8b87550fda13a59cf6..7c6013d0426a23d619ba692f62d6a0e8c8a2bc99 100644 GIT binary patch delta 89 zcmeC3Z`3>AsG)_ig{g(Pg=Gut3ROn4>52BNV$=7lvQ_}u5A9iHwuh*(axsG>AsG)_ig{g(Pg=Gut3ROna>52BNV$=7lvQ_}u5A9iHwuh*(axsG>F7M3lny^@S()8kE8#isKJu*yt7AjvulD6r59$UbGt$~FD6 x6szv^^`@+<+kvb-OhCCrT~@93U9zk|%m&2lK+FNeoIuP4#N69=$?{~x0{}+*BH91| delta 105 zcmZ4V%W%;z!-f{d7N!>F7M3lny^@Tk)8kE8#isKJu*yt7AjvulD6r59$UbGt$~FD6 x6szv^^`@+<+kvb-OhCCrT~@93U9zk|%m&2lK+FNeoIuP4#N69=$?{~x0{})kBG&)_ diff --git a/docs/diagrams/operator-review-workflow.workflow.json b/docs/diagrams/operator-review-workflow.workflow.json index dbcf231..1c0fa80 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": "v5", + "tag": "v6", "width": 124 }, { diff --git a/docs/diagrams/system-architecture.architecture.json b/docs/diagrams/system-architecture.architecture.json index 0b0dbb6..1c57fab 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 v5", + "tag": "RiskScorer v6", "pos": [ 678, 60 diff --git a/docs/diagrams/system-architecture.html b/docs/diagrams/system-architecture.html index 5136e52ee7495ace275d5331c25b956e16899b5d..ab732fa93403c6521779fa90abd9c51592e2ab0f 100644 GIT binary patch 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 delta 83 zcmeBqZP@eLu%U&qg{g(Pg=Gt?qU`j%Uo0ilr^>SSFq%%^$jB 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 diff --git a/tests/rights_filter/analysis/test_risk_scoring.py b/tests/rights_filter/analysis/test_risk_scoring.py index d6baa41..9f27bfe 100644 --- a/tests/rights_filter/analysis/test_risk_scoring.py +++ b/tests/rights_filter/analysis/test_risk_scoring.py @@ -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, diff --git a/tests/rights_filter/test_review_fixes.py b/tests/rights_filter/test_review_fixes.py index 728d3cf..ed42d2b 100644 --- a/tests/rights_filter/test_review_fixes.py +++ b/tests/rights_filter/test_review_fixes.py @@ -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():