feat: 운영자 신원 기록 + 게시처 성격(언론·공식 / 무단 게시처) 표시

④ 감사 actor 31곳이 'rights.ops' 리터럴이라 누가 판정했는지 답이 없었다. ContextVar 로 요청
   범위 운영자를 걸고 스토어는 current_actor() 를 읽는다. 콘솔 좌측 하단 '운영자' 칸 →
   X-Operator 헤더(한글 percent-encode, 서버에서 제어문자 제거·40자). 이름 없으면 판정이
   막힌다. 인증이 아니라 기록 — 권한은 공유 토큰.
⑤ 기준 DB 유형 news_host / scraper_host. 점수는 바꾸지 않고 한 줄 요약에
   '(언론·공식 n · 무단 게시처 m)', 증거 호스트 칩 옆에 배지.

verify.ps1: ruff clean, 684 passed. 실렌더: 운영자 입력칸 표시 확인(44px).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
This commit is contained in:
changukyu 2026-09-26 14:46:20 +09:00
parent 177e365b55
commit 2735ce1194
27 changed files with 366 additions and 51 deletions

View file

@ -1,5 +1,7 @@
# Project INDEX # Project INDEX
- 2026-09-26 · **페르소나 리뷰 권고 ④⑤ — 운영자 신원 + 게시처 성격.** ④ 감사 actor 31곳이 `"rights.ops"` 리터럴이라 "누가 반려했나"에 답이 없었다(법무 2-A 나머지 절반). `operator_identity.py` 의 ContextVar 로 요청 범위 운영자를 걸고 스토어는 `current_actor()` 를 읽는다 — 스토어 메서드 시그니처 무변경, 요청 밖(시드·테스트)은 기본값 유지. 콘솔: 좌측 하단 **운영자** 칸(localStorage), `X-Operator` 헤더(한글은 percent-encode, 서버가 되돌리고 제어문자 제거 — 로그 줄 위조 방지, 40자), 이름 없으면 단건·일괄 판정이 막힌다. 인증이 아니라 기록. E2E 는 init script 로 이름을 넣고 판정. ⑤ 기준 DB 유형 `news_host`(언론·공식)·`scraper_host`(무단 게시처) 신설 — **점수 무관**(판정 데이터 전 가중치 금지), 한 줄 요약이 `웹 완전일치 사본 4곳 (언론·공식 2 · 무단 게시처 1)`, 증거 호스트 칩 옆에 성격 배지(하위 도메인 포함). 테스트: HTTP actor(제어문자 제거 포함) · 스코어러 헤드라인 · 스토어 등록→헤드라인·점수 불변.
- 2026-09-25 · **페르소나 리뷰 권고 1~3.** ① 내부 판정 내보내기가 현재 점수·현재 `사용` 표시 대신 **`decisionSnapshot`(판정 시점)** 을 읽는다 — 판정 뒤 표시를 바꾸거나 리스코어돼도 "무엇을 보고 판정했나"가 바뀌지 않는다. 드리프트를 숨기지 않도록 `currentRiskScore`·`scoredVersion` 열 추가, 스냅샷 없는 옛 행은 현재값 + `no-snapshot`. 지원자용에는 둘 다 안 나간다(테스트). ② '위험' 명칭: 큐 열 `위험` → `검토 우선`(툴팁 "침해 판정이 아닙니다"), 점수 구성 패널에 "웹 노출·인물 식별성으로 매긴 검토 순서, 권리 증빙은 반영 안 됨" 한 줄, 가이드에 라이선스 인물 80 vs 무단 그림 50 예시로 명시. 통계 화면의 고위험/저위험 용어는 밴드 이름이라 유지. ③ `docs/operations/operating-modes.md` 신설 — A 전체 egress / B API 호스트만(결과 페이지 fetch 실패는 예외를 삼켜 **사본 비교가 조용히 빠짐**) / C 완전 폐쇄(**키를 둔 채 망만 막으면 매 호출이 실패 → 큐 전체 30/medium**, 키 제거·비상 차단 시 0 + `외부 검색 꺼짐`). offline-install.md 에서 링크. - 2026-09-25 · **페르소나 리뷰 권고 1~3.** ① 내부 판정 내보내기가 현재 점수·현재 `사용` 표시 대신 **`decisionSnapshot`(판정 시점)** 을 읽는다 — 판정 뒤 표시를 바꾸거나 리스코어돼도 "무엇을 보고 판정했나"가 바뀌지 않는다. 드리프트를 숨기지 않도록 `currentRiskScore`·`scoredVersion` 열 추가, 스냅샷 없는 옛 행은 현재값 + `no-snapshot`. 지원자용에는 둘 다 안 나간다(테스트). ② '위험' 명칭: 큐 열 `위험` → `검토 우선`(툴팁 "침해 판정이 아닙니다"), 점수 구성 패널에 "웹 노출·인물 식별성으로 매긴 검토 순서, 권리 증빙은 반영 안 됨" 한 줄, 가이드에 라이선스 인물 80 vs 무단 그림 50 예시로 명시. 통계 화면의 고위험/저위험 용어는 밴드 이름이라 유지. ③ `docs/operations/operating-modes.md` 신설 — A 전체 egress / B API 호스트만(결과 페이지 fetch 실패는 예외를 삼켜 **사본 비교가 조용히 빠짐**) / C 완전 폐쇄(**키를 둔 채 망만 막으면 매 호출이 실패 → 큐 전체 30/medium**, 키 제거·비상 차단 시 0 + `외부 검색 꺼짐`). offline-install.md 에서 링크.
- 2026-09-25 · **페르소나 × 소크라테스 3단논법 리뷰** (`docs/reviews/2026-09-25-persona-socratic-review.md`). 5 페르소나 9 논증. 붕괴 3: 법무 2-A(actor 익명 + **내보내기가 decisionSnapshot 이 아니라 현재 점수·현재 '사용' 표시를 읽음** — 코드 확인), 법무 2-B(점수는 권리 위험이 아니라 노출×식별성 — '위험' 명칭 과장), 결정자 5-A(판정 0건, 가중치 4회 변경 모두 판정 대조 없음). 권고 1~3(스냅샷 내보내기 · 명칭 교정 · 운영 모드 문서)은 S. - 2026-09-25 · **페르소나 × 소크라테스 3단논법 리뷰** (`docs/reviews/2026-09-25-persona-socratic-review.md`). 5 페르소나 9 논증. 붕괴 3: 법무 2-A(actor 익명 + **내보내기가 decisionSnapshot 이 아니라 현재 점수·현재 '사용' 표시를 읽음** — 코드 확인), 법무 2-B(점수는 권리 위험이 아니라 노출×식별성 — '위험' 명칭 과장), 결정자 5-A(판정 0건, 가중치 4회 변경 모두 판정 대조 없음). 권고 1~3(스냅샷 내보내기 · 명칭 교정 · 운영 모드 문서)은 S.

View file

@ -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~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`) — 큐 '상위 근거' 칸과 케이스 요약 보드 첫 줄이 이것을 쓴다 | | `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` 로 웹 풀에 간다. `RiskScorer(trusted_hosts, host_labels)` — host_labels(`news_host`/`scraper_host` 기준 DB 유형)는 한 줄 요약의 `(언론·공식 n · 무단 게시처 m)` 에만 쓰이고 점수는 안 바꾼다. `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 폐기 |
@ -229,3 +229,7 @@ app-shell CSP(`script-src 'self'`, `http_app.py:514`)가 인라인 스크립트
| architecture spec 만 좌표 하드코딩 | `docs/diagrams/system-architecture.architecture.json` | 컴포넌트 11개 전부 `pos`+`size` 수기, `size` 는 [124,62] 고정. 라벨 **21자부터 validate 하드 실패**(19·20자 통과, 21·22자 실패 — 9/21 실측. 현재 `store_remote_fetch` 18자 → 여유 2글자). workflow spec 은 기하 하드코딩 0개이므로 같은 저장소에 더 나은 패턴이 있다 | | architecture spec 만 좌표 하드코딩 | `docs/diagrams/system-architecture.architecture.json` | 컴포넌트 11개 전부 `pos`+`size` 수기, `size` 는 [124,62] 고정. 라벨 **21자부터 validate 하드 실패**(19·20자 통과, 21·22자 실패 — 9/21 실측. 현재 `store_remote_fetch` 18자 → 여유 2글자). workflow spec 은 기하 하드코딩 0개이므로 같은 저장소에 더 나은 패턴이 있다 |
| lifecycle 기하 손튜닝 8개소 | `docs/diagrams/submission-lifecycle.lifecycle.json` | `labelDy`×5·`channelX`×2·`yOffset`×1 이 현재 배치 전제의 절대 픽셀이다. 상태를 하나 추가하면 **손대지 않은 기존 전이까지** 검증 실패 — 9/21 실측: `waiting` 레인 col 1 에 상태 1개 + 전이 1개만 넣었는데 오류 5건이 났고 그중 3건이 손대지 않은 기존 `t-reject` 쪽이었다. 상태 추가 시 전량 재검증 필요 | | lifecycle 기하 손튜닝 8개소 | `docs/diagrams/submission-lifecycle.lifecycle.json` | `labelDy`×5·`channelX`×2·`yOffset`×1 이 현재 배치 전제의 절대 픽셀이다. 상태를 하나 추가하면 **손대지 않은 기존 전이까지** 검증 실패 — 9/21 실측: `waiting` 레인 col 1 에 상태 1개 + 전이 1개만 넣었는데 오류 5건이 났고 그중 3건이 손대지 않은 기존 `t-reject` 쪽이었다. 상태 추가 시 전량 재검증 필요 |
| 7/11 리뷰 연기 항목 | [reviews/2026-07-11-remediation-checklist.md](reviews/2026-07-11-remediation-checklist.md) | 각 항목에 연기 근거 기재 | | 7/11 리뷰 연기 항목 | [reviews/2026-07-11-remediation-checklist.md](reviews/2026-07-11-remediation-checklist.md) | 각 항목에 연기 근거 기재 |
## 운영자 신원 (2026-09-26)
`server/operator_identity.py` — 콘솔이 `X-Operator` 헤더(percent-encoded)로 보낸 이름을 `http_app` 이 요청마다 `operator_scope` 로 걸고, 스토어의 감사 actor 는 전부 `current_actor()` 를 읽는다(요청 밖 기본값 `rights.ops`). 콘솔 이름은 `localStorage` `copyrighter.operatorName`, 판정 경로는 `requireOperatorName` 으로 막힌다. 인증이 아니라 기록이다.

View file

@ -4,6 +4,12 @@
큐를 위험한 순서로 세워 줄 뿐이고, 승인·보류·반려는 사람이 한다. 이 문서는 그 판정을 큐를 위험한 순서로 세워 줄 뿐이고, 승인·보류·반려는 사람이 한다. 이 문서는 그 판정을
빠르고 일관되게 하는 순서다. 빠르고 일관되게 하는 순서다.
## 0. 시작 전에 — 이름
왼쪽 아래 **운영자** 칸에 이름을 넣는다. 판정·증거 표시·기준 DB 조작이 감사 로그에
`operator:<이름>` 으로 남는다. 이름이 없으면 판정 버튼이 막힌다. 브라우저가 기억하므로 한
번만 넣으면 된다. 이 이름은 기록용이며, 접근 권한은 공유 토큰이 정한다.
## 1. 한 건 심사의 순서 (보통 1~2분) ## 1. 한 건 심사의 순서 (보통 1~2분)
| 단계 | 어디서 | 무엇을 | | 단계 | 어디서 | 무엇을 |
@ -25,7 +31,7 @@
|---|---|---| |---|---|---|
| **등록 권리 대상 일치 0.95** | 기준 DB 에서 운영자가 **확정**한 이미지(주의 후보 승격, 수동 등록)와 같은 이미지 | 단독으로 높음. 이전 판정과 같은 결론이 원칙 — 다르면 이유를 메모 | | **등록 권리 대상 일치 0.95** | 기준 DB 에서 운영자가 **확정**한 이미지(주의 후보 승격, 수동 등록)와 같은 이미지 | 단독으로 높음. 이전 판정과 같은 결론이 원칙 — 다르면 이유를 메모 |
| **주의 후보 일치 0.95** | 보류·반려로 자동 생성된, 아직 확정 안 된 항목과 같은 이미지 | 단독으로 중간 — "누가 다시 보려 했다"는 뜻. 기준 DB 에서 그 항목의 메모를 열어 이전 판정을 확인하고, 반복되면 `승격` 으로 확정한다 | | **주의 후보 일치 0.95** | 보류·반려로 자동 생성된, 아직 확정 안 된 항목과 같은 이미지 | 단독으로 중간 — "누가 다시 보려 했다"는 뜻. 기준 DB 에서 그 항목의 메모를 열어 이전 판정을 확인하고, 반복되면 `승격` 으로 확정한다 |
| **웹 완전일치 사본 N곳** | 검색 결과·구글 이미지 감지에서 **같은 이미지**가 N개 호스트에서 발견 | 가장 중요한 사실. 출처가 언론사·공식 계정이면 그쪽이 원저작자일 가능성이 높다 | | **웹 완전일치 사본 N곳** (언론·공식 a · 무단 게시처 b) | 검색 결과·구글 이미지 감지에서 **같은 이미지**가 N개 호스트에서 발견. 괄호는 기준 DB 에 성격을 등록한 호스트의 수 | 가장 중요한 사실. 언론·공식 쪽이면 원저작자일 가능성이 높다. 자주 보는 호스트는 기준 DB 에 `언론·공식 출처`/`무단 게시처` 로 등록해 두면 다음부터 링크를 열지 않아도 된다(점수는 바뀌지 않음) |
| 웹 부분·페이지 일치 / 웹 유사 이미지 | 잘렸거나 편집된 사본 / 비슷한 구도 | 참고. 부분 일치는 열어 보고, 유사는 앞의 둘이 없을 때만 | | 웹 부분·페이지 일치 / 웹 유사 이미지 | 잘렸거나 편집된 사본 / 비슷한 구도 | 참고. 부분 일치는 열어 보고, 유사는 앞의 둘이 없을 때만 |
| **식별 가능 인물** / 배경 인물 | 얼굴이 알아볼 수 있게 찍힘 / 군중 속 인물 | 초상권 축. 유명인·특정 개인이면 사본이 없어도 보류 검토 | | **식별 가능 인물** / 배경 인물 | 얼굴이 알아볼 수 있게 찍힘 / 군중 속 인물 | 초상권 축. 유명인·특정 개인이면 사본이 없어도 보류 검토 |
| 이름 있는 검색 승격 N건 | 이름(인물·작품)으로 검색해 결과가 나옴 | 약한 참고. 단독으로는 밴드를 만들지 못한다 | | 이름 있는 검색 승격 N건 | 이름(인물·작품)으로 검색해 결과가 나옴 | 약한 참고. 단독으로는 밴드를 만들지 못한다 |

View file

@ -125,7 +125,11 @@ _POOL_CAPS = {
class RiskScorer: class RiskScorer:
def __init__(self, trusted_hosts: frozenset[str] | None = None) -> None: def __init__(
self,
trusted_hosts: frozenset[str] | None = None,
host_labels: dict[str, str] | None = None,
) -> None:
"""trusted_hosts: publishing hosts the team owns or licenses. """trusted_hosts: publishing hosts the team owns or licenses.
Supplied by the store from active `trusted_host` knowledge entries. The Supplied by the store from active `trusted_host` knowledge entries. The
@ -135,6 +139,17 @@ class RiskScorer:
self.trusted_hosts = frozenset( self.trusted_hosts = frozenset(
host for host in (trusted_hosts or frozenset()) if _is_hostname(host) host for host in (trusted_hosts or frozenset()) if _is_hostname(host)
) )
# Display-only categories (news / scraper). They name where a copy was
# found in the headline; they never change the score.
self.host_labels = {
host: label for host, label in (host_labels or {}).items() if _is_hostname(host)
}
def _host_label_of(self, host: str) -> str:
for known, label in self.host_labels.items():
if host == known or host.endswith(f".{known}"):
return label
return ""
def _trusted_host_of(self, item: Evidence) -> str: def _trusted_host_of(self, item: Evidence) -> str:
"""The registered trusted host this row was published on, if any. """The registered trusted host this row was published on, if any.
@ -372,7 +387,7 @@ class RiskScorer:
{"label": f"신뢰 출처 {host}", "rows": rows} {"label": f"신뢰 출처 {host}", "rows": rows}
for host, rows in sorted(excluded.items()) for host, rows in sorted(excluded.items())
], ],
"headline": _headline(tally, sum(excluded.values())), "headline": _headline(tally, sum(excluded.values()), self._host_label_of),
} }
return ScoreResult(score=score, band=band, reasons=_unique(reasons), breakdown=breakdown) return ScoreResult(score=score, band=band, reasons=_unique(reasons), breakdown=breakdown)
@ -395,6 +410,21 @@ class _Tally:
not_run: bool = False not_run: bool = False
def _host_breakdown(places: set[str], host_label_of: Any) -> str:
"""" (언론·공식 2 · 무단 게시처 1)" for the places the team has characterised."""
if host_label_of is None:
return ""
counts: dict[str, int] = {}
for place in places:
label = host_label_of(place)
if label:
counts[label] = counts.get(label, 0) + 1
if not counts:
return ""
ordered = [label for label in ("언론·공식", "무단 게시처") if label in counts]
return " (" + " · ".join(f"{label} {counts[label]}" for label in ordered) + ")"
def _presence_tier(points: int) -> str: def _presence_tier(points: int) -> str:
if points >= WEB_COPY_EXACT_POINTS: if points >= WEB_COPY_EXACT_POINTS:
return "exact" return "exact"
@ -415,7 +445,7 @@ def _face_label(item: Evidence) -> str:
return "식별 가능 인물" if tier else "인물" return "식별 가능 인물" if tier else "인물"
def _headline(tally: _Tally, excluded_rows: int) -> str: def _headline(tally: _Tally, excluded_rows: int, host_label_of: Any = None) -> str:
"""운영자가 첫 1초에 읽는 한 줄. 사유 목록의 압축이고 순서는 위 예산표를 따른다. """운영자가 첫 1초에 읽는 한 줄. 사유 목록의 압축이고 순서는 위 예산표를 따른다.
실 콘솔에서 큐의 '상위 근거' 칸은 `구글 visually s... 구글 페이지 with ...` 처럼 실 콘솔에서 큐의 '상위 근거' 칸은 `구글 visually s... 구글 페이지 with ...` 처럼
@ -431,12 +461,12 @@ def _headline(tally: _Tally, excluded_rows: int) -> str:
parts.append(f"주의 후보 일치 {tally.watchlist_best:.2f}") parts.append(f"주의 후보 일치 {tally.watchlist_best:.2f}")
if tally.cleared: if tally.cleared:
parts.append("클리어 이력 일치") parts.append("클리어 이력 일치")
if tally.presence["exact"]: for tier, text, unit in (("exact", "웹 완전일치 사본", "곳"), ("near", "웹 부분·페이지 일치", "곳"), ("weak", "웹 유사 이미지", "건")):
parts.append(f"웹 완전일치 사본 {len(tally.presence['exact'])}곳") places = tally.presence[tier]
elif tally.presence["near"]: if not places:
parts.append(f"웹 부분·페이지 일치 {len(tally.presence['near'])}곳") continue
elif tally.presence["weak"]: parts.append(f"{text} {len(places)}{unit}{_host_breakdown(places, host_label_of)}")
parts.append(f"웹 유사 이미지 {len(tally.presence['weak'])}건") break
if tally.face: if tally.face:
parts.append(tally.face) parts.append(tally.face)
if tally.naver_hits: if tally.naver_hits:

View file

@ -46,6 +46,13 @@ class KnowledgeEntryType(StrEnum):
# web-detection rows from that host score zero instead of +45 "it is on the # web-detection rows from that host score zero instead of +45 "it is on the
# web". The entry's name is the host; aliases carry the party's other hosts. # web". The entry's name is the host; aliases carry the party's other hosts.
TRUSTED_HOST = "trusted_host" TRUSTED_HOST = "trusted_host"
# Publishing hosts the team has characterised, for display only - they do not
# change the score (no decision data yet says how much they should). A copy on
# a news / official host usually points at the original rights holder; one on
# a known scraper is a republication. Operators opened every link to tell the
# two apart (persona review 2026-09-25, 운영자 1-B).
NEWS_HOST = "news_host"
SCRAPER_HOST = "scraper_host"
OTHER = "other" OTHER = "other"

View file

@ -15,6 +15,7 @@ from urllib.parse import parse_qs, unquote, urlparse
from rights_filter.analysis.risk_scoring import SCORER_VERSION from rights_filter.analysis.risk_scoring import SCORER_VERSION
from rights_filter.server.auth_rate_limit import FailedAuthRateLimiter from rights_filter.server.auth_rate_limit import FailedAuthRateLimiter
from rights_filter.server.operator_identity import OPERATOR_HEADER, clean_operator_name, operator_scope
from rights_filter.server.image_store import LocalSubmissionImageStore, SUPPORTED_IMAGE_SUFFIXES from rights_filter.server.image_store import LocalSubmissionImageStore, SUPPORTED_IMAGE_SUFFIXES
from rights_filter.server.sqlite_store import CopyrighterStore from rights_filter.server.sqlite_store import CopyrighterStore
from rights_filter.version import release_version from rights_filter.version import release_version
@ -99,7 +100,22 @@ def build_server(
# a handful of slowloris-style connections cannot exhaust the thread pool. # a handful of slowloris-style connections cannot exhaust the thread pool.
timeout = 30 timeout = 30
# Every request runs under the operator name the console sent, so the
# store's audit events say who acted (see operator_identity).
def _as_operator(self, handler) -> None:
with operator_scope(clean_operator_name(self.headers.get(OPERATOR_HEADER))):
handler()
def do_GET(self) -> None: def do_GET(self) -> None:
self._as_operator(self._handle_GET)
def do_POST(self) -> None:
self._as_operator(self._handle_POST)
def do_PATCH(self) -> None:
self._as_operator(self._handle_PATCH)
def _handle_GET(self) -> None:
path = _path(self.path) path = _path(self.path)
if not self._require_auth(): if not self._require_auth():
return return
@ -163,7 +179,7 @@ def build_server(
except ValueError as exc: except ValueError as exc:
self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def do_POST(self) -> None: def _handle_POST(self) -> None:
path = _path(self.path) path = _path(self.path)
if not self._require_auth(): if not self._require_auth():
return return
@ -314,7 +330,7 @@ def build_server(
except ValueError as exc: except ValueError as exc:
self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def do_PATCH(self) -> None: def _handle_PATCH(self) -> None:
path = _path(self.path) path = _path(self.path)
if not self._require_auth(): if not self._require_auth():
return return

View file

@ -0,0 +1,54 @@
"""Who is acting - the operator name that goes on every audit event.
Until 2026-09-26 every audit actor was the literal "rights.ops", so "who
rejected this?" had no answer (persona review, 법무 2-A). The console now sends
the operator's name in an `X-Operator` header; the HTTP layer scopes it to the
request with `operator_scope`, and the store reads it through `current_actor()`.
A ContextVar keeps the store's method signatures unchanged: each request runs in
its own thread (ThreadingHTTPServer), so the scope never leaks between requests,
and store calls made outside a request (seeding at startup, tests) keep the
shared default.
This is attribution, not authentication. The shared COPYRIGHTER_AUTH_TOKEN
decides who may act; this records which operator said they acted.
"""
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar
DEFAULT_ACTOR = "rights.ops"
OPERATOR_HEADER = "X-Operator"
MAX_OPERATOR_NAME = 40
_current: ContextVar[str] = ContextVar("copyrighter_operator", default=DEFAULT_ACTOR)
def current_actor() -> str:
return _current.get()
def clean_operator_name(raw: str | None) -> str:
"""The header value as an audit actor, or "" when it is unusable.
HTTP headers arrive latin-1 decoded; the console percent-encodes the name so
Korean survives, and this reverses it. Control characters are dropped so a
name cannot forge extra lines in an exported audit log.
"""
from urllib.parse import unquote
text = unquote(str(raw or "")).strip()
text = "".join(ch for ch in text if ch.isprintable())
return text[:MAX_OPERATOR_NAME]
@contextmanager
def operator_scope(name: str) -> Iterator[None]:
token = _current.set(f"operator:{name}" if name else DEFAULT_ACTOR)
try:
yield
finally:
_current.reset(token)

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from rights_filter.server.operator_identity import current_actor
import json import json
import logging import logging
import os import os
@ -431,7 +432,7 @@ class CopyrighterStore(
internal_analyzer=analyzer, internal_analyzer=analyzer,
external_adapter=self.provider_runtime.google_adapter or CloudVisionWebDetectionAdapter(FakeWebDetectionClient()), external_adapter=self.provider_runtime.google_adapter or CloudVisionWebDetectionAdapter(FakeWebDetectionClient()),
external_policy=self.provider_runtime.external_policy if vision_allowed else ExternalApiPolicy(disabled=True), external_policy=self.provider_runtime.external_policy if vision_allowed else ExternalApiPolicy(disabled=True),
scorer=RiskScorer(self._trusted_hosts()), scorer=RiskScorer(self._trusted_hosts(), self._host_labels()),
) )
submissions = [ submissions = [
SubmissionImage(record["id"], image_store.image_payload(record["id"])) SubmissionImage(record["id"], image_store.image_payload(record["id"]))
@ -852,7 +853,7 @@ class CopyrighterStore(
) )
self.add_audit_event( self.add_audit_event(
actor="rights.ops", actor=current_actor(),
event="Operator decision created", event="Operator decision created",
object_id=submission_id, object_id=submission_id,
change=( change=(
@ -889,14 +890,14 @@ class CopyrighterStore(
payload["operatorStatusLabel"] = EVIDENCE_OPERATOR_STATUSES[status] payload["operatorStatusLabel"] = EVIDENCE_OPERATOR_STATUSES[status]
payload["operatorStatusNote"] = note.strip() payload["operatorStatusNote"] = note.strip()
payload["operatorStatusAt"] = _now_label() payload["operatorStatusAt"] = _now_label()
payload["operatorStatusActor"] = "rights.ops" payload["operatorStatusActor"] = current_actor()
if status in NON_CONTRIBUTING_OPERATOR_STATUSES: if status in NON_CONTRIBUTING_OPERATOR_STATUSES:
payload["contributed"] = False payload["contributed"] = False
else: else:
payload["contributed"] = _default_evidence_contribution(payload) payload["contributed"] = _default_evidence_contribution(payload)
self._put("evidence", evidence_id, payload, conn=conn) self._put("evidence", evidence_id, payload, conn=conn)
self.add_audit_event( self.add_audit_event(
actor="rights.ops", actor=current_actor(),
event="Evidence status changed", event="Evidence status changed",
object_id=evidence_id, object_id=evidence_id,
change=f"{EVIDENCE_OPERATOR_STATUSES[status]} · {note or 'note optional'}", change=f"{EVIDENCE_OPERATOR_STATUSES[status]} · {note or 'note optional'}",
@ -953,7 +954,7 @@ class CopyrighterStore(
payload["operatorStatusLabel"] = label payload["operatorStatusLabel"] = label
payload["operatorStatusNote"] = note.strip() payload["operatorStatusNote"] = note.strip()
payload["operatorStatusAt"] = stamped_at payload["operatorStatusAt"] = stamped_at
payload["operatorStatusActor"] = "rights.ops" payload["operatorStatusActor"] = current_actor()
if status in NON_CONTRIBUTING_OPERATOR_STATUSES: if status in NON_CONTRIBUTING_OPERATOR_STATUSES:
payload["contributed"] = False payload["contributed"] = False
else: else:
@ -961,7 +962,7 @@ class CopyrighterStore(
self._put("evidence", evidence_id, payload, conn=conn) self._put("evidence", evidence_id, payload, conn=conn)
self.add_audit_event( self.add_audit_event(
actor="rights.ops", actor=current_actor(),
event="Evidence status changed in batch", event="Evidence status changed in batch",
object_id=submission_id, object_id=submission_id,
change=f"{label} · {len(payloads)}건 · {note or 'note optional'}", change=f"{label} · {len(payloads)}건 · {note or 'note optional'}",

View file

@ -13,6 +13,7 @@ through `Connection.backup` rather than shutil.
from __future__ import annotations from __future__ import annotations
from rights_filter.server.operator_identity import current_actor
import shutil import shutil
import sqlite3 import sqlite3
import zipfile import zipfile
@ -69,7 +70,7 @@ class StoreBackupMixin:
pruned = _prune_old_backups(backup_dir, retention) pruned = _prune_old_backups(backup_dir, retention)
size_bytes = archive_path.stat().st_size size_bytes = archive_path.stat().st_size
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Backup snapshot created", "Backup snapshot created",
archive_path.name, archive_path.name,
f"{size_bytes} bytes · media {media_counts} · pruned {pruned}", f"{size_bytes} bytes · media {media_counts} · pruned {pruned}",

View file

@ -9,6 +9,7 @@ rescoring provided by the host class. Behavior unchanged.
from __future__ import annotations from __future__ import annotations
from rights_filter.server.operator_identity import current_actor
import json import json
import logging import logging
from dataclasses import replace from dataclasses import replace
@ -425,7 +426,7 @@ class StoreEnrichmentMixin:
google_provider["lastFailure"] = "없음" google_provider["lastFailure"] = "없음"
self._apply_provider_usage_delta("google", call_count, google_provider) self._apply_provider_usage_delta("google", call_count, google_provider)
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Provider called", "Provider called",
f"google / {submission_id}", f"google / {submission_id}",
"image web detection rerun", "image web detection rerun",

View file

@ -9,6 +9,7 @@ and self.* attributes provided by the host class. Behavior unchanged.
from __future__ import annotations from __future__ import annotations
from rights_filter.server.operator_identity import current_actor
import json import json
import logging import logging
import sqlite3 import sqlite3
@ -46,10 +47,10 @@ class StoreOperationsMixin:
entry["active"] = True entry["active"] = True
entry["excludedReason"] = "" entry["excludedReason"] = ""
entry["confirmedAt"] = _now_label() entry["confirmedAt"] = _now_label()
entry["confirmedBy"] = "rights.ops" entry["confirmedBy"] = current_actor()
self._put("knowledge_entries", entry_id, entry) self._put("knowledge_entries", entry_id, entry)
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Watchlist candidate promoted", "Watchlist candidate promoted",
str(entry.get("name", entry_id)), str(entry.get("name", entry_id)),
"promoted into confirmed reference DB", "promoted into confirmed reference DB",
@ -64,10 +65,10 @@ class StoreOperationsMixin:
entry["active"] = False entry["active"] = False
entry["excludedReason"] = reason.strip() or "오탐 또는 무관 후보" entry["excludedReason"] = reason.strip() or "오탐 또는 무관 후보"
entry["excludedAt"] = _now_label() entry["excludedAt"] = _now_label()
entry["excludedBy"] = "rights.ops" entry["excludedBy"] = current_actor()
self._put("knowledge_entries", entry_id, entry) self._put("knowledge_entries", entry_id, entry)
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Watchlist candidate excluded", "Watchlist candidate excluded",
str(entry.get("name", entry_id)), str(entry.get("name", entry_id)),
entry["excludedReason"], entry["excludedReason"],
@ -94,7 +95,7 @@ class StoreOperationsMixin:
entry.update(updates) entry.update(updates)
self._put("knowledge_entries", entry_id, entry) self._put("knowledge_entries", entry_id, entry)
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Knowledge entry updated", "Knowledge entry updated",
str(entry.get("name", entry_id)), str(entry.get("name", entry_id)),
f"{json.dumps(before, ensure_ascii=False)} -> {json.dumps(updates, ensure_ascii=False)}", f"{json.dumps(before, ensure_ascii=False)} -> {json.dumps(updates, ensure_ascii=False)}",
@ -109,11 +110,11 @@ class StoreOperationsMixin:
raise ValueError("이미 비활성 상태입니다") raise ValueError("이미 비활성 상태입니다")
entry["active"] = False entry["active"] = False
entry["deactivatedAt"] = _now_label() entry["deactivatedAt"] = _now_label()
entry["deactivatedBy"] = "rights.ops" entry["deactivatedBy"] = current_actor()
entry["deactivatedReason"] = reason.strip() entry["deactivatedReason"] = reason.strip()
self._put("knowledge_entries", entry_id, entry) self._put("knowledge_entries", entry_id, entry)
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Knowledge entry deactivated", "Knowledge entry deactivated",
str(entry.get("name", entry_id)), str(entry.get("name", entry_id)),
reason.strip() or "운영자 비활성화", reason.strip() or "운영자 비활성화",
@ -130,11 +131,11 @@ class StoreOperationsMixin:
raise ValueError("이미 활성 상태입니다") raise ValueError("이미 활성 상태입니다")
entry["active"] = True entry["active"] = True
entry["reactivatedAt"] = _now_label() entry["reactivatedAt"] = _now_label()
entry["reactivatedBy"] = "rights.ops" entry["reactivatedBy"] = current_actor()
entry["reactivatedReason"] = reason.strip() entry["reactivatedReason"] = reason.strip()
self._put("knowledge_entries", entry_id, entry) self._put("knowledge_entries", entry_id, entry)
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Knowledge entry reactivated", "Knowledge entry reactivated",
str(entry.get("name", entry_id)), str(entry.get("name", entry_id)),
reason.strip(), reason.strip(),
@ -156,7 +157,7 @@ class StoreOperationsMixin:
return [] return []
if entry.get("entryStatus") == "confirmed": if entry.get("entryStatus") == "confirmed":
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Watchlist candidate kept despite approval", "Watchlist candidate kept despite approval",
str(entry.get("name", entry_id)), str(entry.get("name", entry_id)),
"확정 항목이라 자동 철회하지 않음 - 수동 검토 필요", "확정 항목이라 자동 철회하지 않음 - 수동 검토 필요",
@ -169,10 +170,10 @@ class StoreOperationsMixin:
entry["active"] = False entry["active"] = False
entry["excludedReason"] = "승인으로 판정 변경 - 자동 철회" entry["excludedReason"] = "승인으로 판정 변경 - 자동 철회"
entry["excludedAt"] = _now_label() entry["excludedAt"] = _now_label()
entry["excludedBy"] = "rights.ops" entry["excludedBy"] = current_actor()
self._put("knowledge_entries", entry_id, entry, conn=conn) self._put("knowledge_entries", entry_id, entry, conn=conn)
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Watchlist candidate withdrawn", "Watchlist candidate withdrawn",
str(entry.get("name", entry_id)), str(entry.get("name", entry_id)),
entry["excludedReason"], entry["excludedReason"],
@ -327,7 +328,7 @@ class StoreOperationsMixin:
} }
self._put("knowledge_entries", entry_id, entry, conn=conn) self._put("knowledge_entries", entry_id, entry, conn=conn)
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Cleared reference registered", "Cleared reference registered",
submission_id, submission_id,
entry["name"], entry["name"],
@ -392,7 +393,7 @@ class StoreOperationsMixin:
self._ensure_llm_summary(submission_id) self._ensure_llm_summary(submission_id)
self.add_audit_event("rights.ops", "Analysis run created", submission_id, "operator rerun") self.add_audit_event(current_actor(), "Analysis run created", submission_id, "operator rerun")
self._rescore_submission(submission_id) self._rescore_submission(submission_id)
self._sync_submission_provider_state() self._sync_submission_provider_state()
@ -457,7 +458,7 @@ class StoreOperationsMixin:
self._rescore_submission(submission_id) self._rescore_submission(submission_id)
self._sync_submission_provider_state() self._sync_submission_provider_state()
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Provider called", "Provider called",
f"auto-search / {submission_id}", f"auto-search / {submission_id}",
"operator request for auto text search", "operator request for auto text search",
@ -533,7 +534,7 @@ class StoreOperationsMixin:
if image_store is not None: if image_store is not None:
self._rescore_submission(submission_id) self._rescore_submission(submission_id)
self._sync_submission_provider_state() self._sync_submission_provider_state()
self.add_audit_event("rights.ops", "Provider called", f"{provider} / {submission_id}", f"manual text query: {query}") self.add_audit_event(current_actor(), "Provider called", f"{provider} / {submission_id}", f"manual text query: {query}")
return self.review(submission_id) return self.review(submission_id)
def _ensure_llm_summaries_for_existing_source_evidence(self, queue_id: str | None = None) -> None: def _ensure_llm_summaries_for_existing_source_evidence(self, queue_id: str | None = None) -> None:
@ -756,7 +757,7 @@ class StoreOperationsMixin:
} }
self._put("knowledge_entries", entry_id, entry) self._put("knowledge_entries", entry_id, entry)
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Knowledge entry manually created", "Knowledge entry manually created",
name, name,
"manual image reference" if image_record else "manual text reference", "manual image reference" if image_record else "manual text reference",
@ -882,7 +883,7 @@ class StoreOperationsMixin:
for candidate in deduped_candidates: for candidate in deduped_candidates:
self._put("collection_candidates", candidate["id"], candidate, conn=conn) self._put("collection_candidates", candidate["id"], candidate, conn=conn)
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Keyword candidates collected", "Keyword candidates collected",
provider, provider,
f"{query} · {collected} candidates", f"{query} · {collected} candidates",
@ -928,7 +929,7 @@ class StoreOperationsMixin:
candidate["promotedKnowledgeId"] = entry_id candidate["promotedKnowledgeId"] = entry_id
self._put("collection_candidates", candidate_id, candidate, conn=conn) self._put("collection_candidates", candidate_id, candidate, conn=conn)
self.add_audit_event( self.add_audit_event(
"rights.ops", "Knowledge entry manually created", name, current_actor(), "Knowledge entry manually created", name,
f"promoted candidate {candidate_id}", conn=conn, f"promoted candidate {candidate_id}", conn=conn,
) )
return self.bootstrap() return self.bootstrap()
@ -950,7 +951,7 @@ class StoreOperationsMixin:
"delete from collection_candidates where id = ?", (candidate_id,) "delete from collection_candidates where id = ?", (candidate_id,)
) )
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Keyword candidate dismissed", "Keyword candidate dismissed",
candidate_id, candidate_id,
f"dismissed candidate for query: {query}", f"dismissed candidate for query: {query}",
@ -1030,7 +1031,7 @@ class StoreOperationsMixin:
candidate["promotedKnowledgeId"] = entry_id candidate["promotedKnowledgeId"] = entry_id
self._put("collection_candidates", str(candidate["id"]), candidate, conn=conn) self._put("collection_candidates", str(candidate["id"]), candidate, conn=conn)
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Knowledge entry manually created", "Knowledge entry manually created",
name, name,
f"promoted {len(candidates)} collected candidates", f"promoted {len(candidates)} collected candidates",

View file

@ -729,9 +729,20 @@ class StoreSearchCandidatesMixin:
Malformed values are dropped by RiskScorer, so validity lives in one Malformed values are dropped by RiskScorer, so validity lives in one
place rather than being re-checked at every call site. place rather than being re-checked at every call site.
""" """
return frozenset(self._hosts_of_type("trusted_host"))
def _host_labels(self) -> dict[str, str]:
"""Host -> operator-facing category, for the headline only (no score)."""
labels: dict[str, str] = {}
for entry_type, label in (("news_host", "언론·공식"), ("scraper_host", "무단 게시처")):
for host in self._hosts_of_type(entry_type):
labels.setdefault(host, label)
return labels
def _hosts_of_type(self, entry_type: str) -> set[str]:
hosts: set[str] = set() hosts: set[str] = set()
for entry in self._all("knowledge_entries"): for entry in self._all("knowledge_entries"):
if str(entry.get("type", "")) != "trusted_host": if str(entry.get("type", "")) != entry_type:
continue continue
if not entry.get("active", True): if not entry.get("active", True):
continue continue
@ -739,7 +750,7 @@ class StoreSearchCandidatesMixin:
text = str(value).strip().lower().removeprefix("www.") text = str(value).strip().lower().removeprefix("www.")
if text: if text:
hosts.add(text) hosts.add(text)
return frozenset(hosts) return hosts
def _rescore_submission(self, submission_id: str) -> None: def _rescore_submission(self, submission_id: str) -> None:
submission = self._get("submissions", submission_id) submission = self._get("submissions", submission_id)
@ -747,7 +758,7 @@ class StoreSearchCandidatesMixin:
_domain_evidence_from_ui(item) _domain_evidence_from_ui(item)
for item in self._evidence_for_submission(submission_id) for item in self._evidence_for_submission(submission_id)
] ]
score = RiskScorer(self._trusted_hosts()).score(evidence) score = RiskScorer(self._trusted_hosts(), self._host_labels()).score(evidence)
previous_band = str(submission.get("riskBand", "")) previous_band = str(submission.get("riskBand", ""))
previous_score = submission.get("riskScore", "") previous_score = submission.get("riskScore", "")
# The version the LIVE score was last produced by, as opposed to the # The version the LIVE score was last produced by, as opposed to the

View file

@ -7,6 +7,7 @@ attributes provided by the host class.
from __future__ import annotations from __future__ import annotations
from rights_filter.server.operator_identity import current_actor
import csv import csv
import io import io
from typing import Any from typing import Any
@ -134,7 +135,7 @@ class StoreWorkflowMixin:
failed.append({"submissionId": submission_id, "error": str(exc)}) failed.append({"submissionId": submission_id, "error": str(exc)})
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Operator batch decision", "Operator batch decision",
f"{decision} x{len(succeeded)}", f"{decision} x{len(succeeded)}",
f"{len(succeeded)} applied, {len(failed)} failed · {memo or 'memo optional'}", f"{len(succeeded)} applied, {len(failed)} failed · {memo or 'memo optional'}",
@ -179,7 +180,7 @@ class StoreWorkflowMixin:
}, },
) )
self.add_audit_event( self.add_audit_event(
"rights.ops", "Decision memo preset saved", preset_id, clean_label current_actor(), "Decision memo preset saved", preset_id, clean_label
) )
return self.decision_memo_presets() return self.decision_memo_presets()
@ -191,7 +192,7 @@ class StoreWorkflowMixin:
if not cursor.rowcount: if not cursor.rowcount:
raise KeyError(preset_id) raise KeyError(preset_id)
self.add_audit_event( self.add_audit_event(
"rights.ops", "Decision memo preset deleted", str(preset_id), "removed" current_actor(), "Decision memo preset deleted", str(preset_id), "removed"
) )
return self.decision_memo_presets() return self.decision_memo_presets()
@ -307,7 +308,7 @@ class StoreWorkflowMixin:
APPLICANT_EXPORT_FIELDS if audience == "applicant" else INTERNAL_EXPORT_FIELDS APPLICANT_EXPORT_FIELDS if audience == "applicant" else INTERNAL_EXPORT_FIELDS
) )
self.add_audit_event( self.add_audit_event(
"rights.ops", current_actor(),
"Decisions exported", "Decisions exported",
audience, audience,
f"{len(rows)} case(s) · status={wanted_status or 'all'} · since={since_text or 'all'}", f"{len(rows)} case(s) · status={wanted_status or 'all'} · since={since_text or 'all'}",

View file

@ -91,6 +91,9 @@ def browser() -> Iterator[Any]:
def operator_page(browser: Any, request: pytest.FixtureRequest) -> Iterator[Any]: def operator_page(browser: Any, request: pytest.FixtureRequest) -> Iterator[Any]:
"""A fresh page at the default viewport; screenshots to artifacts on failure.""" """A fresh page at the default viewport; screenshots to artifacts on failure."""
page = browser.new_page(viewport=DEFAULT_VIEWPORT) page = browser.new_page(viewport=DEFAULT_VIEWPORT)
# The console refuses decisions until an operator name is set (audit "who").
# Specs drive decisions as a named operator, the way a real session would.
page.add_init_script("window.localStorage.setItem('copyrighter.operatorName', 'e2e 운영자')")
try: try:
yield page yield page
finally: finally:

View file

@ -923,6 +923,24 @@ def test_headline_says_when_the_external_check_was_off_without_scoring_it():
) )
def test_headline_says_which_copies_are_on_news_or_scraper_hosts_without_scoring_them():
# Persona review 2026-09-25 (운영자 1-B): "4곳" did not say whether they were
# the original publisher or republications, so every link was opened anyway.
evidence = [
_search_result_copy(1.0, "https://news.example.kr/a.jpg"),
_search_result_copy(1.0, "https://cdn.news.example.kr/b.jpg"),
_search_result_copy(1.0, "https://scrape.example/c.jpg"),
_search_result_copy(1.0, "https://other.example/d.jpg"),
]
labels = {"news.example.kr": "언론·공식", "scrape.example": "무단 게시처"}
labelled = RiskScorer(host_labels=labels).score(evidence)
plain = RiskScorer().score(evidence)
assert labelled.breakdown["headline"] == "웹 완전일치 사본 4곳 (언론·공식 2 · 무단 게시처 1)"
assert labelled.score == plain.score
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([]) == "신호 없음"

View file

@ -1649,3 +1649,33 @@ def test_operational_error_surfaces_as_503(tmp_path: Path, monkeypatch):
assert exc.value.code == 503 assert exc.value.code == 503
finally: finally:
server.shutdown() server.shutdown()
def test_audit_events_name_the_operator_the_console_sent(tmp_path: Path):
# Persona review 2026-09-25 (법무 2-A): every audit actor was "rights.ops",
# so "who rejected this?" had no answer.
from urllib.parse import quote
static_dir, image_store, store = _fixtures(tmp_path)
server = build_server("127.0.0.1", 0, store, image_store=image_store, static_dir=static_dir)
_start(server)
base = f"http://127.0.0.1:{server.server_port}"
submission_id = str(store.bootstrap()["submissions"][0]["id"])
try:
request = Request(
f"{base}/api/submissions/{submission_id}/decision",
data=json.dumps({"decision": "rejected", "memo": "타인 저작물"}).encode("utf-8"),
headers={"Content-Type": "application/json", "X-Operator": quote("김심사\r\nforged")},
method="POST",
)
with urlopen(request, timeout=10):
pass
finally:
server.shutdown()
server.server_close()
decision_event = next(event for event in store.audit_events() if event["event"] == "Operator decision created")
# Control characters are dropped so a name cannot forge a second log line.
assert decision_event["actor"] == "operator:김심사forged"
# Outside a request (seeding) the shared default is kept.
assert any(event["actor"] == "rights.system" or event["actor"] == "rights.ops" for event in store.audit_events())

View file

@ -8770,6 +8770,25 @@ def test_a_registered_trusted_host_removes_its_web_presence_from_the_score(tmp_p
assert any("신뢰 출처" in reason for reason in after["reasons"]) assert any("신뢰 출처" in reason for reason in after["reasons"])
def test_a_registered_news_host_labels_the_headline_but_leaves_the_score(tmp_path: Path):
# Persona review 2026-09-25 (운영자 1-B): display-only host categories.
image_root = tmp_path / "submissions"
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
store.initialize()
store.seed_from_image_store(_image_store(image_root))
submission_id = str(store.bootstrap()["submissions"][0]["id"])
_web_evidence_on(store, submission_id, "photo.news.example.kr")
store._rescore_submission(submission_id)
before = store._get("submissions", submission_id)
store.register_manual_knowledge_entry({"name": "news.example.kr", "type": "news_host"})
store._rescore_submission(submission_id)
after = store._get("submissions", submission_id)
assert after["riskScore"] == before["riskScore"]
assert "(언론·공식 1)" in after["riskBreakdown"]["headline"]
def test_deactivating_a_trusted_host_entry_restores_the_score(tmp_path: Path): def test_deactivating_a_trusted_host_entry_restores_the_score(tmp_path: Path):
# 신뢰 철회에 별도 스위치를 두지 않는다 - 지식 항목의 기존 활성/비활성 # 신뢰 철회에 별도 스위치를 두지 않는다 - 지식 항목의 기존 활성/비활성
# 수명주기를 그대로 쓴다. # 수명주기를 그대로 쓴다.

View file

@ -91,6 +91,13 @@ function authHeaders(extra) {
} }
if (operatorName) {
// 헤더는 latin-1 이라 한글 이름은 인코딩해 보낸다. 서버가 되돌린다.
headers["X-Operator"] = encodeURIComponent(operatorName);
}
return headers; return headers;
} }

View file

@ -224,6 +224,12 @@ function bindEvents() {
document.getElementById("mark-irrelevant").addEventListener("click", () => markEvidenceIrrelevant()); document.getElementById("mark-irrelevant").addEventListener("click", () => markEvidenceIrrelevant());
const operatorField = document.getElementById("operator-name");
if (operatorField) {
operatorField.value = operatorName;
operatorField.addEventListener("change", () => setOperatorName(operatorField.value));
}
document.getElementById("disable-derived").addEventListener("click", () => { document.getElementById("disable-derived").addEventListener("click", () => {
// Scope the deactivation to the currently-selected case. Picking the first // Scope the deactivation to the currently-selected case. Picking the first
// automatic entry globally could deactivate an entry that belongs to a // automatic entry globally could deactivate an entry that belongs to a

View file

@ -592,6 +592,23 @@ function formatDomain(domain) {
// Evidence stored before the server persisted `host` still carries its result // Evidence stored before the server persisted `host` still carries its result
// URL, so derive the hostname here rather than migrating 292+ rows or paying a // URL, so derive the hostname here rather than migrating 292+ rows or paying a
// parse per row on every bootstrap. Only rendered rows reach this. // parse per row on every bootstrap. Only rendered rows reach this.
function knownHostCategory(host) {
// Mirrors the scorer's suffix match: a registered host covers its subdomains.
const clean = String(host || "").toLowerCase().replace(/^www\./, "");
for (const entry of knowledgeEntries) {
if (entry.active === false) continue;
const kind = entry.type === "news_host" ? "news" : entry.type === "scraper_host" ? "scraper" : "";
if (!kind) continue;
for (const value of [entry.name, ...(entry.aliases || [])]) {
const known = String(value || "").trim().toLowerCase().replace(/^www\./, "");
if (known && (clean === known || clean.endsWith(`.${known}`))) {
return { kind, label: kind === "news" ? "언론·공식" : "무단 게시처" };
}
}
}
return "";
}
function evidenceHost(evidence) { function evidenceHost(evidence) {
if (evidence.host) return evidence.host; if (evidence.host) return evidence.host;
// Same candidates, in the same order, as _result_host in // Same candidates, in the same order, as _result_host in

View file

@ -425,6 +425,8 @@ async function applyBulkDecision(decision) {
} }
if (!requireOperatorName(setStatus)) return;
const memo = document.getElementById("decision-memo")?.value.trim() || ""; const memo = document.getElementById("decision-memo")?.value.trim() || "";
if (decision === "rejected" && !memo) { if (decision === "rejected" && !memo) {

View file

@ -238,6 +238,8 @@ async function setDecision(decision, requiresMemo = false) {
} }
if (!requireOperatorName((message) => { error.textContent = message; })) return;
error.textContent = ""; error.textContent = "";

View file

@ -327,8 +327,12 @@ function renderEvidenceRow(evidence) {
// CDN hosts sharing a 20-character prefix are otherwise indistinguishable // CDN hosts sharing a 20-character prefix are otherwise indistinguishable
// without opening the link, which is the discrimination the monospace font // without opening the link, which is the discrimination the monospace font
// was chosen for. // was chosen for.
// 기준 DB 에 성격을 등록한 호스트면 칩 옆에 붙인다(언론·공식 / 무단 게시처).
const hostCategory = hostName ? knownHostCategory(hostName) : "";
const host = hostName const host = hostName
? `<span class="host-chip" title="게시 호스트 ${escapeHtml(hostName)}">${escapeHtml(hostName)}</span>` ? `<span class="host-chip" title="게시 호스트 ${escapeHtml(hostName)}">${escapeHtml(hostName)}</span>${
hostCategory ? `<span class="host-category host-category-${hostCategory.kind}">${escapeHtml(hostCategory.label)}</span>` : ""
}`
: ""; : "";
const hasPreview = Boolean(evidence.thumbnailUrl || evidence.imageUrl); const hasPreview = Boolean(evidence.thumbnailUrl || evidence.imageUrl);
const preview = renderEvidencePreview(evidence); const preview = renderEvidencePreview(evidence);

View file

@ -134,6 +134,35 @@ const viewNames = new Set(["queue", "workbench", "knowledge", "providers", "stat
let authToken = ""; let authToken = "";
// 판정·증거 표시를 누가 했는지 감사 로그에 남기는 운영자 이름. 인증이 아니라 기록용이다
// (권한은 공유 토큰이 정한다). 비밀이 아니므로 브라우저별로 기억해 매번 묻지 않는다.
const OPERATOR_NAME_KEY = "copyrighter.operatorName";
let operatorName = (() => {
try {
return (window.localStorage.getItem(OPERATOR_NAME_KEY) || "").trim();
} catch (_error) {
return "";
}
})();
function setOperatorName(value) {
operatorName = String(value || "").trim().slice(0, 40);
try {
window.localStorage.setItem(OPERATOR_NAME_KEY, operatorName);
} catch (_error) {
// 저장이 막힌 브라우저에서도 이번 세션 동안은 동작한다.
}
}
// 이름 없이 판정하면 감사 로그의 "누가"가 비어 버린다. 판정 경로들이 이것으로 막는다.
function requireOperatorName(showError) {
if (operatorName) return true;
showError("판정 전에 왼쪽 아래 '운영자' 칸에 이름을 입력하세요 — 감사 기록에 남습니다.");
document.getElementById("operator-name")?.focus();
return false;
}
let pendingAuthPrompt = null; let pendingAuthPrompt = null;

View file

@ -86,7 +86,10 @@
<div class="nav-footer"> <div class="nav-footer">
<span class="status-dot ok" aria-hidden="true"></span> <span class="status-dot ok" aria-hidden="true"></span>
<span>내부 전용</span> <label class="operator-name-field" for="operator-name">
<span>운영자</span>
<input id="operator-name" type="text" maxlength="40" autocomplete="name" placeholder="이름 (감사 기록용)">
</label>
</div> </div>
</nav> </nav>
@ -407,6 +410,8 @@
<option value="game">게임</option> <option value="game">게임</option>
<option value="rejected_image">반려 이미지</option> <option value="rejected_image">반려 이미지</option>
<option value="trusted_host">신뢰 출처</option> <option value="trusted_host">신뢰 출처</option>
<option value="news_host">언론·공식 출처</option>
<option value="scraper_host">무단 게시처</option>
<option value="other">기타</option> <option value="other">기타</option>
</select> </select>
</label> </label>
@ -483,6 +488,8 @@
<option value="game">게임</option> <option value="game">게임</option>
<option value="rejected_reference">반려 이미지 참조</option> <option value="rejected_reference">반려 이미지 참조</option>
<option value="trusted_host">신뢰 출처(이름·별칭에 도메인)</option> <option value="trusted_host">신뢰 출처(이름·별칭에 도메인)</option>
<option value="news_host">언론·공식 출처(표시만, 점수 무관)</option>
<option value="scraper_host">무단 게시처(표시만, 점수 무관)</option>
</select> </select>
</label> </label>
<label> <label>

View file

@ -82,6 +82,8 @@
rejected_reference: "반려 이미지", rejected_reference: "반려 이미지",
cleared_reference: "클리어 기준", cleared_reference: "클리어 기준",
trusted_host: "신뢰 출처", trusted_host: "신뢰 출처",
news_host: "언론·공식 출처",
scraper_host: "무단 게시처",
other: "기타", other: "기타",
}; };

View file

@ -295,6 +295,24 @@ textarea:disabled {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.operator-name-field {
display: grid;
gap: 4px;
flex: 1;
min-width: 0;
}
.operator-name-field input {
min-height: 44px;
padding: 4px 8px;
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: var(--r-sm);
background: rgba(255, 255, 255, 0.08);
color: var(--rail-ink);
font: inherit;
letter-spacing: 0;
}
.nav-footer { .nav-footer {
display: flex; display: flex;
align-items: center; align-items: center;
@ -1374,6 +1392,22 @@ tbody tr.selected-row,
color: var(--ink-soft); color: var(--ink-soft);
} }
.host-category {
flex: 0 0 auto;
padding: 1px 6px;
border: 1px solid var(--hair-strong);
border-radius: 999px;
font-size: 11px;
font-weight: 600;
color: var(--ink-soft);
}
.host-category-scraper {
border-color: var(--amber-line);
background: var(--amber-tint);
color: var(--amber);
}
.evidence-status-chip, .evidence-status-chip,
.watchlist-chip.watchlist { .watchlist-chip.watchlist {
border-color: var(--amber-line); border-color: var(--amber-line);