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:
parent
177e365b55
commit
2735ce1194
27 changed files with 366 additions and 51 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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` 으로 막힌다. 인증이 아니라 기록이다.
|
||||||
|
|
|
||||||
|
|
@ -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건 | 이름(인물·작품)으로 검색해 결과가 나옴 | 약한 참고. 단독으로는 밴드를 만들지 못한다 |
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
54
src/rights_filter/server/operator_identity.py
Normal file
54
src/rights_filter/server/operator_identity.py
Normal 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)
|
||||||
|
|
@ -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'}",
|
||||||
|
|
|
||||||
|
|
@ -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}",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'}",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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([]) == "신호 없음"
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
# 신뢰 철회에 별도 스위치를 두지 않는다 - 지식 항목의 기존 활성/비활성
|
# 신뢰 철회에 별도 스위치를 두지 않는다 - 지식 항목의 기존 활성/비활성
|
||||||
# 수명주기를 그대로 쓴다.
|
# 수명주기를 그대로 쓴다.
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,13 @@ function authHeaders(extra) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (operatorName) {
|
||||||
|
|
||||||
|
// 헤더는 latin-1 이라 한글 이름은 인코딩해 보낸다. 서버가 되돌린다.
|
||||||
|
headers["X-Operator"] = encodeURIComponent(operatorName);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
return headers;
|
return headers;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,8 @@ async function setDecision(decision, requiresMemo = false) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!requireOperatorName((message) => { error.textContent = message; })) return;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
error.textContent = "";
|
error.textContent = "";
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,8 @@
|
||||||
rejected_reference: "반려 이미지",
|
rejected_reference: "반려 이미지",
|
||||||
cleared_reference: "클리어 기준",
|
cleared_reference: "클리어 기준",
|
||||||
trusted_host: "신뢰 출처",
|
trusted_host: "신뢰 출처",
|
||||||
|
news_host: "언론·공식 출처",
|
||||||
|
scraper_host: "무단 게시처",
|
||||||
other: "기타",
|
other: "기타",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue