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 18a3c25..7c6013d 100644 Binary files a/docs/diagrams/evidence-dataflow.html and b/docs/diagrams/evidence-dataflow.html differ diff --git a/docs/diagrams/manifest.json b/docs/diagrams/manifest.json index 334aaa3..9135c8c 100644 --- a/docs/diagrams/manifest.json +++ b/docs/diagrams/manifest.json @@ -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 }, { diff --git a/docs/diagrams/operator-review-workflow.html b/docs/diagrams/operator-review-workflow.html index d962854..832b59b 100644 Binary files a/docs/diagrams/operator-review-workflow.html and b/docs/diagrams/operator-review-workflow.html differ 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 5136e52..ab732fa 100644 Binary files a/docs/diagrams/system-architecture.html and b/docs/diagrams/system-architecture.html differ diff --git a/docs/operations/calibration-corpus.md b/docs/operations/calibration-corpus.md index bb9e767..7ece899 100644 --- a/docs/operations/calibration-corpus.md +++ b/docs/operations/calibration-corpus.md @@ -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 diff --git a/src/rights_filter/analysis/risk_scoring.py b/src/rights_filter/analysis/risk_scoring.py index 274a3f9..21d7d8c 100644 --- a/src/rights_filter/analysis/risk_scoring.py +++ b/src/rights_filter/analysis/risk_scoring.py @@ -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 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():