diff --git a/docs/INDEX.md b/docs/INDEX.md index c799ac0..4d0e829 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,5 +1,7 @@ # 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 · **페르소나 × 소크라테스 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. diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index b710b68..707993b 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -41,7 +41,7 @@ python run_copyrighter_server.py # 기본 127.0.0.1:9500 | `fingerprints.py` | 이미지 지문. `_perceptual_hash`는 **DCT pHash가 아니라 8x8 평균해시(aHash)** — 이름과 실제가 다름(알려진 부채) | | `face_person_detection.py` | YuNet(ONNX) 얼굴 검출 + 인물 등급(`identifiable`/`background`). 가중치는 `models/`(gitignore) | | `internal_analyzer.py` | 내부 분석 오케스트레이션 | -| `risk_scoring.py` | `RiskScorer.score()` — 점수·밴드 산출. **`SCORER_VERSION` 은 가중치/버킷/밴드 변경 시 반드시 올린다**. `RiskScorer(trusted_hosts)` 는 지식DB 의 활성 `trusted_host` 항목(이름·별칭이 도메인)을 받아 그 호스트에서 발견된 웹 탐지 행을 0점 처리한다 — **행 단위 제외**라 자사 사본과 스크래퍼 게시가 같이 있으면 후자는 그대로 센다. v5~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` | 증거 정규화·보강 | | `search_query_generation.py`, `search_result_promoter.py` | 자동 검색 쿼리 생성, 검색 결과의 증거 승격 | | `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개이므로 같은 저장소에 더 나은 패턴이 있다 | | 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) | 각 항목에 연기 근거 기재 | + +## 운영자 신원 (2026-09-26) + +`server/operator_identity.py` — 콘솔이 `X-Operator` 헤더(percent-encoded)로 보낸 이름을 `http_app` 이 요청마다 `operator_scope` 로 걸고, 스토어의 감사 actor 는 전부 `current_actor()` 를 읽는다(요청 밖 기본값 `rights.ops`). 콘솔 이름은 `localStorage` `copyrighter.operatorName`, 판정 경로는 `requireOperatorName` 으로 막힌다. 인증이 아니라 기록이다. diff --git a/docs/operations/review-guide.md b/docs/operations/review-guide.md index 79688f3..f67dc43 100644 --- a/docs/operations/review-guide.md +++ b/docs/operations/review-guide.md @@ -4,6 +4,12 @@ 큐를 위험한 순서로 세워 줄 뿐이고, 승인·보류·반려는 사람이 한다. 이 문서는 그 판정을 빠르고 일관되게 하는 순서다. +## 0. 시작 전에 — 이름 + +왼쪽 아래 **운영자** 칸에 이름을 넣는다. 판정·증거 표시·기준 DB 조작이 감사 로그에 +`operator:<이름>` 으로 남는다. 이름이 없으면 판정 버튼이 막힌다. 브라우저가 기억하므로 한 +번만 넣으면 된다. 이 이름은 기록용이며, 접근 권한은 공유 토큰이 정한다. + ## 1. 한 건 심사의 순서 (보통 1~2분) | 단계 | 어디서 | 무엇을 | @@ -25,7 +31,7 @@ |---|---|---| | **등록 권리 대상 일치 0.95** | 기준 DB 에서 운영자가 **확정**한 이미지(주의 후보 승격, 수동 등록)와 같은 이미지 | 단독으로 높음. 이전 판정과 같은 결론이 원칙 — 다르면 이유를 메모 | | **주의 후보 일치 0.95** | 보류·반려로 자동 생성된, 아직 확정 안 된 항목과 같은 이미지 | 단독으로 중간 — "누가 다시 보려 했다"는 뜻. 기준 DB 에서 그 항목의 메모를 열어 이전 판정을 확인하고, 반복되면 `승격` 으로 확정한다 | -| **웹 완전일치 사본 N곳** | 검색 결과·구글 이미지 감지에서 **같은 이미지**가 N개 호스트에서 발견 | 가장 중요한 사실. 출처가 언론사·공식 계정이면 그쪽이 원저작자일 가능성이 높다 | +| **웹 완전일치 사본 N곳** (언론·공식 a · 무단 게시처 b) | 검색 결과·구글 이미지 감지에서 **같은 이미지**가 N개 호스트에서 발견. 괄호는 기준 DB 에 성격을 등록한 호스트의 수 | 가장 중요한 사실. 언론·공식 쪽이면 원저작자일 가능성이 높다. 자주 보는 호스트는 기준 DB 에 `언론·공식 출처`/`무단 게시처` 로 등록해 두면 다음부터 링크를 열지 않아도 된다(점수는 바뀌지 않음) | | 웹 부분·페이지 일치 / 웹 유사 이미지 | 잘렸거나 편집된 사본 / 비슷한 구도 | 참고. 부분 일치는 열어 보고, 유사는 앞의 둘이 없을 때만 | | **식별 가능 인물** / 배경 인물 | 얼굴이 알아볼 수 있게 찍힘 / 군중 속 인물 | 초상권 축. 유명인·특정 개인이면 사본이 없어도 보류 검토 | | 이름 있는 검색 승격 N건 | 이름(인물·작품)으로 검색해 결과가 나옴 | 약한 참고. 단독으로는 밴드를 만들지 못한다 | diff --git a/src/rights_filter/analysis/risk_scoring.py b/src/rights_filter/analysis/risk_scoring.py index e14b40e..e0e41d2 100644 --- a/src/rights_filter/analysis/risk_scoring.py +++ b/src/rights_filter/analysis/risk_scoring.py @@ -125,7 +125,11 @@ _POOL_CAPS = { 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. Supplied by the store from active `trusted_host` knowledge entries. The @@ -135,6 +139,17 @@ class RiskScorer: self.trusted_hosts = frozenset( 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: """The registered trusted host this row was published on, if any. @@ -372,7 +387,7 @@ class RiskScorer: {"label": f"신뢰 출처 {host}", "rows": rows} 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) @@ -395,6 +410,21 @@ class _Tally: 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: if points >= WEB_COPY_EXACT_POINTS: return "exact" @@ -415,7 +445,7 @@ def _face_label(item: Evidence) -> str: 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초에 읽는 한 줄. 사유 목록의 압축이고 순서는 위 예산표를 따른다. 실 콘솔에서 큐의 '상위 근거' 칸은 `구글 visually s... 구글 페이지 with ...` 처럼 @@ -431,12 +461,12 @@ def _headline(tally: _Tally, excluded_rows: int) -> str: parts.append(f"주의 후보 일치 {tally.watchlist_best:.2f}") if tally.cleared: parts.append("클리어 이력 일치") - if tally.presence["exact"]: - parts.append(f"웹 완전일치 사본 {len(tally.presence['exact'])}곳") - elif tally.presence["near"]: - parts.append(f"웹 부분·페이지 일치 {len(tally.presence['near'])}곳") - elif tally.presence["weak"]: - parts.append(f"웹 유사 이미지 {len(tally.presence['weak'])}건") + for tier, text, unit in (("exact", "웹 완전일치 사본", "곳"), ("near", "웹 부분·페이지 일치", "곳"), ("weak", "웹 유사 이미지", "건")): + places = tally.presence[tier] + if not places: + continue + parts.append(f"{text} {len(places)}{unit}{_host_breakdown(places, host_label_of)}") + break if tally.face: parts.append(tally.face) if tally.naver_hits: diff --git a/src/rights_filter/domain/records.py b/src/rights_filter/domain/records.py index a2908e2..ef3c530 100644 --- a/src/rights_filter/domain/records.py +++ b/src/rights_filter/domain/records.py @@ -46,6 +46,13 @@ class KnowledgeEntryType(StrEnum): # 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. 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" diff --git a/src/rights_filter/server/http_app.py b/src/rights_filter/server/http_app.py index 189fd2b..3a827e6 100644 --- a/src/rights_filter/server/http_app.py +++ b/src/rights_filter/server/http_app.py @@ -15,6 +15,7 @@ from urllib.parse import parse_qs, unquote, urlparse from rights_filter.analysis.risk_scoring import SCORER_VERSION 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.sqlite_store import CopyrighterStore 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. 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: + 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) if not self._require_auth(): return @@ -163,7 +179,7 @@ def build_server( except ValueError as exc: self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) - def do_POST(self) -> None: + def _handle_POST(self) -> None: path = _path(self.path) if not self._require_auth(): return @@ -314,7 +330,7 @@ def build_server( except ValueError as exc: self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) - def do_PATCH(self) -> None: + def _handle_PATCH(self) -> None: path = _path(self.path) if not self._require_auth(): return diff --git a/src/rights_filter/server/operator_identity.py b/src/rights_filter/server/operator_identity.py new file mode 100644 index 0000000..b62fb02 --- /dev/null +++ b/src/rights_filter/server/operator_identity.py @@ -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) diff --git a/src/rights_filter/server/sqlite_store.py b/src/rights_filter/server/sqlite_store.py index d3af57e..253d514 100644 --- a/src/rights_filter/server/sqlite_store.py +++ b/src/rights_filter/server/sqlite_store.py @@ -1,5 +1,6 @@ from __future__ import annotations +from rights_filter.server.operator_identity import current_actor import json import logging import os @@ -431,7 +432,7 @@ class CopyrighterStore( internal_analyzer=analyzer, external_adapter=self.provider_runtime.google_adapter or CloudVisionWebDetectionAdapter(FakeWebDetectionClient()), 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 = [ SubmissionImage(record["id"], image_store.image_payload(record["id"])) @@ -852,7 +853,7 @@ class CopyrighterStore( ) self.add_audit_event( - actor="rights.ops", + actor=current_actor(), event="Operator decision created", object_id=submission_id, change=( @@ -889,14 +890,14 @@ class CopyrighterStore( payload["operatorStatusLabel"] = EVIDENCE_OPERATOR_STATUSES[status] payload["operatorStatusNote"] = note.strip() payload["operatorStatusAt"] = _now_label() - payload["operatorStatusActor"] = "rights.ops" + payload["operatorStatusActor"] = current_actor() if status in NON_CONTRIBUTING_OPERATOR_STATUSES: payload["contributed"] = False else: payload["contributed"] = _default_evidence_contribution(payload) self._put("evidence", evidence_id, payload, conn=conn) self.add_audit_event( - actor="rights.ops", + actor=current_actor(), event="Evidence status changed", object_id=evidence_id, change=f"{EVIDENCE_OPERATOR_STATUSES[status]} · {note or 'note optional'}", @@ -953,7 +954,7 @@ class CopyrighterStore( payload["operatorStatusLabel"] = label payload["operatorStatusNote"] = note.strip() payload["operatorStatusAt"] = stamped_at - payload["operatorStatusActor"] = "rights.ops" + payload["operatorStatusActor"] = current_actor() if status in NON_CONTRIBUTING_OPERATOR_STATUSES: payload["contributed"] = False else: @@ -961,7 +962,7 @@ class CopyrighterStore( self._put("evidence", evidence_id, payload, conn=conn) self.add_audit_event( - actor="rights.ops", + actor=current_actor(), event="Evidence status changed in batch", object_id=submission_id, change=f"{label} · {len(payloads)}건 · {note or 'note optional'}", diff --git a/src/rights_filter/server/store_backup.py b/src/rights_filter/server/store_backup.py index 3e7d9c9..23ae273 100644 --- a/src/rights_filter/server/store_backup.py +++ b/src/rights_filter/server/store_backup.py @@ -13,6 +13,7 @@ through `Connection.backup` rather than shutil. from __future__ import annotations +from rights_filter.server.operator_identity import current_actor import shutil import sqlite3 import zipfile @@ -69,7 +70,7 @@ class StoreBackupMixin: pruned = _prune_old_backups(backup_dir, retention) size_bytes = archive_path.stat().st_size self.add_audit_event( - "rights.ops", + current_actor(), "Backup snapshot created", archive_path.name, f"{size_bytes} bytes · media {media_counts} · pruned {pruned}", diff --git a/src/rights_filter/server/store_enrichment.py b/src/rights_filter/server/store_enrichment.py index 66f1cfa..67e7df0 100644 --- a/src/rights_filter/server/store_enrichment.py +++ b/src/rights_filter/server/store_enrichment.py @@ -9,6 +9,7 @@ rescoring provided by the host class. Behavior unchanged. from __future__ import annotations +from rights_filter.server.operator_identity import current_actor import json import logging from dataclasses import replace @@ -425,7 +426,7 @@ class StoreEnrichmentMixin: google_provider["lastFailure"] = "없음" self._apply_provider_usage_delta("google", call_count, google_provider) self.add_audit_event( - "rights.ops", + current_actor(), "Provider called", f"google / {submission_id}", "image web detection rerun", diff --git a/src/rights_filter/server/store_operations.py b/src/rights_filter/server/store_operations.py index 427dae1..3c6224d 100644 --- a/src/rights_filter/server/store_operations.py +++ b/src/rights_filter/server/store_operations.py @@ -9,6 +9,7 @@ and self.* attributes provided by the host class. Behavior unchanged. from __future__ import annotations +from rights_filter.server.operator_identity import current_actor import json import logging import sqlite3 @@ -46,10 +47,10 @@ class StoreOperationsMixin: entry["active"] = True entry["excludedReason"] = "" entry["confirmedAt"] = _now_label() - entry["confirmedBy"] = "rights.ops" + entry["confirmedBy"] = current_actor() self._put("knowledge_entries", entry_id, entry) self.add_audit_event( - "rights.ops", + current_actor(), "Watchlist candidate promoted", str(entry.get("name", entry_id)), "promoted into confirmed reference DB", @@ -64,10 +65,10 @@ class StoreOperationsMixin: entry["active"] = False entry["excludedReason"] = reason.strip() or "오탐 또는 무관 후보" entry["excludedAt"] = _now_label() - entry["excludedBy"] = "rights.ops" + entry["excludedBy"] = current_actor() self._put("knowledge_entries", entry_id, entry) self.add_audit_event( - "rights.ops", + current_actor(), "Watchlist candidate excluded", str(entry.get("name", entry_id)), entry["excludedReason"], @@ -94,7 +95,7 @@ class StoreOperationsMixin: entry.update(updates) self._put("knowledge_entries", entry_id, entry) self.add_audit_event( - "rights.ops", + current_actor(), "Knowledge entry updated", str(entry.get("name", entry_id)), f"{json.dumps(before, ensure_ascii=False)} -> {json.dumps(updates, ensure_ascii=False)}", @@ -109,11 +110,11 @@ class StoreOperationsMixin: raise ValueError("이미 비활성 상태입니다") entry["active"] = False entry["deactivatedAt"] = _now_label() - entry["deactivatedBy"] = "rights.ops" + entry["deactivatedBy"] = current_actor() entry["deactivatedReason"] = reason.strip() self._put("knowledge_entries", entry_id, entry) self.add_audit_event( - "rights.ops", + current_actor(), "Knowledge entry deactivated", str(entry.get("name", entry_id)), reason.strip() or "운영자 비활성화", @@ -130,11 +131,11 @@ class StoreOperationsMixin: raise ValueError("이미 활성 상태입니다") entry["active"] = True entry["reactivatedAt"] = _now_label() - entry["reactivatedBy"] = "rights.ops" + entry["reactivatedBy"] = current_actor() entry["reactivatedReason"] = reason.strip() self._put("knowledge_entries", entry_id, entry) self.add_audit_event( - "rights.ops", + current_actor(), "Knowledge entry reactivated", str(entry.get("name", entry_id)), reason.strip(), @@ -156,7 +157,7 @@ class StoreOperationsMixin: return [] if entry.get("entryStatus") == "confirmed": self.add_audit_event( - "rights.ops", + current_actor(), "Watchlist candidate kept despite approval", str(entry.get("name", entry_id)), "확정 항목이라 자동 철회하지 않음 - 수동 검토 필요", @@ -169,10 +170,10 @@ class StoreOperationsMixin: entry["active"] = False entry["excludedReason"] = "승인으로 판정 변경 - 자동 철회" entry["excludedAt"] = _now_label() - entry["excludedBy"] = "rights.ops" + entry["excludedBy"] = current_actor() self._put("knowledge_entries", entry_id, entry, conn=conn) self.add_audit_event( - "rights.ops", + current_actor(), "Watchlist candidate withdrawn", str(entry.get("name", entry_id)), entry["excludedReason"], @@ -327,7 +328,7 @@ class StoreOperationsMixin: } self._put("knowledge_entries", entry_id, entry, conn=conn) self.add_audit_event( - "rights.ops", + current_actor(), "Cleared reference registered", submission_id, entry["name"], @@ -392,7 +393,7 @@ class StoreOperationsMixin: 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._sync_submission_provider_state() @@ -457,7 +458,7 @@ class StoreOperationsMixin: self._rescore_submission(submission_id) self._sync_submission_provider_state() self.add_audit_event( - "rights.ops", + current_actor(), "Provider called", f"auto-search / {submission_id}", "operator request for auto text search", @@ -533,7 +534,7 @@ class StoreOperationsMixin: if image_store is not None: self._rescore_submission(submission_id) 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) 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.add_audit_event( - "rights.ops", + current_actor(), "Knowledge entry manually created", name, "manual image reference" if image_record else "manual text reference", @@ -882,7 +883,7 @@ class StoreOperationsMixin: for candidate in deduped_candidates: self._put("collection_candidates", candidate["id"], candidate, conn=conn) self.add_audit_event( - "rights.ops", + current_actor(), "Keyword candidates collected", provider, f"{query} · {collected} candidates", @@ -928,7 +929,7 @@ class StoreOperationsMixin: candidate["promotedKnowledgeId"] = entry_id self._put("collection_candidates", candidate_id, candidate, conn=conn) 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, ) return self.bootstrap() @@ -950,7 +951,7 @@ class StoreOperationsMixin: "delete from collection_candidates where id = ?", (candidate_id,) ) self.add_audit_event( - "rights.ops", + current_actor(), "Keyword candidate dismissed", candidate_id, f"dismissed candidate for query: {query}", @@ -1030,7 +1031,7 @@ class StoreOperationsMixin: candidate["promotedKnowledgeId"] = entry_id self._put("collection_candidates", str(candidate["id"]), candidate, conn=conn) self.add_audit_event( - "rights.ops", + current_actor(), "Knowledge entry manually created", name, f"promoted {len(candidates)} collected candidates", diff --git a/src/rights_filter/server/store_search_candidates.py b/src/rights_filter/server/store_search_candidates.py index e975d70..b1dd004 100644 --- a/src/rights_filter/server/store_search_candidates.py +++ b/src/rights_filter/server/store_search_candidates.py @@ -729,9 +729,20 @@ class StoreSearchCandidatesMixin: Malformed values are dropped by RiskScorer, so validity lives in one 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() for entry in self._all("knowledge_entries"): - if str(entry.get("type", "")) != "trusted_host": + if str(entry.get("type", "")) != entry_type: continue if not entry.get("active", True): continue @@ -739,7 +750,7 @@ class StoreSearchCandidatesMixin: text = str(value).strip().lower().removeprefix("www.") if text: hosts.add(text) - return frozenset(hosts) + return hosts def _rescore_submission(self, submission_id: str) -> None: submission = self._get("submissions", submission_id) @@ -747,7 +758,7 @@ class StoreSearchCandidatesMixin: _domain_evidence_from_ui(item) 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_score = submission.get("riskScore", "") # The version the LIVE score was last produced by, as opposed to the diff --git a/src/rights_filter/server/store_workflow.py b/src/rights_filter/server/store_workflow.py index 9482c79..badd3b0 100644 --- a/src/rights_filter/server/store_workflow.py +++ b/src/rights_filter/server/store_workflow.py @@ -7,6 +7,7 @@ attributes provided by the host class. from __future__ import annotations +from rights_filter.server.operator_identity import current_actor import csv import io from typing import Any @@ -134,7 +135,7 @@ class StoreWorkflowMixin: failed.append({"submissionId": submission_id, "error": str(exc)}) self.add_audit_event( - "rights.ops", + current_actor(), "Operator batch decision", f"{decision} x{len(succeeded)}", f"{len(succeeded)} applied, {len(failed)} failed · {memo or 'memo optional'}", @@ -179,7 +180,7 @@ class StoreWorkflowMixin: }, ) 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() @@ -191,7 +192,7 @@ class StoreWorkflowMixin: if not cursor.rowcount: raise KeyError(preset_id) 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() @@ -307,7 +308,7 @@ class StoreWorkflowMixin: APPLICANT_EXPORT_FIELDS if audience == "applicant" else INTERNAL_EXPORT_FIELDS ) self.add_audit_event( - "rights.ops", + current_actor(), "Decisions exported", audience, f"{len(rows)} case(s) · status={wanted_status or 'all'} · since={since_text or 'all'}", diff --git a/tests/operator_gui/conftest.py b/tests/operator_gui/conftest.py index fc4dccf..8f87850 100644 --- a/tests/operator_gui/conftest.py +++ b/tests/operator_gui/conftest.py @@ -91,6 +91,9 @@ def browser() -> Iterator[Any]: def operator_page(browser: Any, request: pytest.FixtureRequest) -> Iterator[Any]: """A fresh page at the default viewport; screenshots to artifacts on failure.""" 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: yield page finally: diff --git a/tests/rights_filter/analysis/test_risk_scoring.py b/tests/rights_filter/analysis/test_risk_scoring.py index 2ce8709..b160dd9 100644 --- a/tests/rights_filter/analysis/test_risk_scoring.py +++ b/tests/rights_filter/analysis/test_risk_scoring.py @@ -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(): assert _headline_of([_published_on("posar.info")], frozenset({"posar.info"})) == "신뢰 출처 제외 1행" assert _headline_of([]) == "신호 없음" diff --git a/tests/rights_filter/server/test_http_app.py b/tests/rights_filter/server/test_http_app.py index c6d60c6..2d373fa 100644 --- a/tests/rights_filter/server/test_http_app.py +++ b/tests/rights_filter/server/test_http_app.py @@ -1649,3 +1649,33 @@ def test_operational_error_surfaces_as_503(tmp_path: Path, monkeypatch): assert exc.value.code == 503 finally: 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()) diff --git a/tests/rights_filter/server/test_sqlite_store.py b/tests/rights_filter/server/test_sqlite_store.py index d606c68..1a22ddb 100644 --- a/tests/rights_filter/server/test_sqlite_store.py +++ b/tests/rights_filter/server/test_sqlite_store.py @@ -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"]) +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): # 신뢰 철회에 별도 스위치를 두지 않는다 - 지식 항목의 기존 활성/비활성 # 수명주기를 그대로 쓴다. diff --git a/web/operator-gui/app-api.js b/web/operator-gui/app-api.js index 3898a16..038dfc2 100644 --- a/web/operator-gui/app-api.js +++ b/web/operator-gui/app-api.js @@ -91,6 +91,13 @@ function authHeaders(extra) { } + if (operatorName) { + + // 헤더는 latin-1 이라 한글 이름은 인코딩해 보낸다. 서버가 되돌린다. + headers["X-Operator"] = encodeURIComponent(operatorName); + + } + return headers; } diff --git a/web/operator-gui/app-bootstrap.js b/web/operator-gui/app-bootstrap.js index 7b84b44..acd429a 100644 --- a/web/operator-gui/app-bootstrap.js +++ b/web/operator-gui/app-bootstrap.js @@ -224,6 +224,12 @@ function bindEvents() { 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", () => { // Scope the deactivation to the currently-selected case. Picking the first // automatic entry globally could deactivate an entry that belongs to a diff --git a/web/operator-gui/app-format.js b/web/operator-gui/app-format.js index 2a350ea..fe75ef6 100644 --- a/web/operator-gui/app-format.js +++ b/web/operator-gui/app-format.js @@ -592,6 +592,23 @@ function formatDomain(domain) { // 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 // 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) { if (evidence.host) return evidence.host; // Same candidates, in the same order, as _result_host in diff --git a/web/operator-gui/app-keyboard-batch.js b/web/operator-gui/app-keyboard-batch.js index e059285..f164a7b 100644 --- a/web/operator-gui/app-keyboard-batch.js +++ b/web/operator-gui/app-keyboard-batch.js @@ -425,6 +425,8 @@ async function applyBulkDecision(decision) { } + if (!requireOperatorName(setStatus)) return; + const memo = document.getElementById("decision-memo")?.value.trim() || ""; if (decision === "rejected" && !memo) { diff --git a/web/operator-gui/app-navigation.js b/web/operator-gui/app-navigation.js index 868f01c..585ada6 100644 --- a/web/operator-gui/app-navigation.js +++ b/web/operator-gui/app-navigation.js @@ -238,6 +238,8 @@ async function setDecision(decision, requiresMemo = false) { } + if (!requireOperatorName((message) => { error.textContent = message; })) return; + error.textContent = ""; diff --git a/web/operator-gui/app-render-evidence.js b/web/operator-gui/app-render-evidence.js index e2bba2a..bddda73 100644 --- a/web/operator-gui/app-render-evidence.js +++ b/web/operator-gui/app-render-evidence.js @@ -327,8 +327,12 @@ function renderEvidenceRow(evidence) { // CDN hosts sharing a 20-character prefix are otherwise indistinguishable // without opening the link, which is the discrimination the monospace font // was chosen for. + // 기준 DB 에 성격을 등록한 호스트면 칩 옆에 붙인다(언론·공식 / 무단 게시처). + const hostCategory = hostName ? knownHostCategory(hostName) : ""; const host = hostName - ? `${escapeHtml(hostName)}` + ? `${escapeHtml(hostName)}${ + hostCategory ? `${escapeHtml(hostCategory.label)}` : "" + }` : ""; const hasPreview = Boolean(evidence.thumbnailUrl || evidence.imageUrl); const preview = renderEvidencePreview(evidence); diff --git a/web/operator-gui/app-state.js b/web/operator-gui/app-state.js index 203b4fb..b19aa61 100644 --- a/web/operator-gui/app-state.js +++ b/web/operator-gui/app-state.js @@ -134,6 +134,35 @@ const viewNames = new Set(["queue", "workbench", "knowledge", "providers", "stat 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; diff --git a/web/operator-gui/index.html b/web/operator-gui/index.html index 9055fef..bfe96cf 100644 --- a/web/operator-gui/index.html +++ b/web/operator-gui/index.html @@ -86,7 +86,10 @@ @@ -407,6 +410,8 @@ + + @@ -483,6 +488,8 @@ + +