diff --git a/src/rights_filter/server/__main__.py b/src/rights_filter/server/__main__.py index b81719a..d54f80f 100644 --- a/src/rights_filter/server/__main__.py +++ b/src/rights_filter/server/__main__.py @@ -2,12 +2,14 @@ from __future__ import annotations import argparse import os +import sys from pathlib import Path from rights_filter.integrations.env_clients import build_provider_runtime from rights_filter.server.env_file import load_env_file from rights_filter.server.http_app import build_server from rights_filter.server.image_store import LocalSubmissionImageStore +from rights_filter.server.store_backup import restore_backup from rights_filter.server.sqlite_store import CopyrighterStore @@ -24,6 +26,16 @@ def _provider_readiness(runtime) -> str: def main() -> None: + # When the server is started as a service with stdout redirected to a file, + # Python switches to block buffering and the startup diagnostics below — + # including the "token not set" security warning — sit in the buffer + # indefinitely (the process never exits). Line-buffer so they reach the log + # immediately. + try: + sys.stdout.reconfigure(line_buffering=True) + except (AttributeError, OSError): + pass + parser = argparse.ArgumentParser(description="Run the local Copyrighter API server.") parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=9500) @@ -31,8 +43,21 @@ def main() -> None: parser.add_argument("--images", default="data/submissions") parser.add_argument("--static", default="web/operator-gui") parser.add_argument("--env", default=".env") + parser.add_argument( + "--restore", + metavar="SNAPSHOT_ZIP", + help="Restore a backup snapshot into --db and exit (server must be stopped).", + ) args = parser.parse_args() + if args.restore: + # Restore is offline on purpose: swapping the database under a running + # server would leave open connections on a file that no longer exists. + result = restore_backup(args.restore, Path(args.db)) + print(f"Restored database: {result['database']}") + print(f"Restored media files: {result['mediaFiles']}") + return + env_path = Path(args.env) load_env_file(env_path, os.environ) # Log the resolved env path (and whether it was found) so an operator running diff --git a/src/rights_filter/server/http_app.py b/src/rights_filter/server/http_app.py index 9044339..0dac217 100644 --- a/src/rights_filter/server/http_app.py +++ b/src/rights_filter/server/http_app.py @@ -11,7 +11,7 @@ from http import HTTPStatus from http.cookies import SimpleCookie from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from urllib.parse import unquote, urlparse +from urllib.parse import parse_qs, unquote, urlparse from rights_filter.server.image_store import LocalSubmissionImageStore, SUPPORTED_IMAGE_SUFFIXES from rights_filter.server.sqlite_store import CopyrighterStore @@ -114,6 +114,19 @@ def build_server( self._json(store.providers()) elif path == "/api/audit-events": self._json(store.audit_events()) + elif path == "/api/decision-memo-presets": + self._json(store.decision_memo_presets()) + elif path == "/api/decisions/export": + query = parse_qs(urlparse(self.path).query) + self._json( + store.export_decisions( + audience=(query.get("audience", ["internal"])[0]), + status=(query.get("status", [""])[0]), + since=(query.get("since", [""])[0]), + ) + ) + elif path == "/api/admin/backups": + self._json(store.backups()) elif path.startswith("/media/"): self._file(active_store().media_path(unquote(path.removeprefix("/media/"))), untrusted=True) elif path.startswith("/knowledge-media/"): @@ -229,6 +242,29 @@ def build_server( elif path.startswith("/api/knowledge/") and path.endswith("/reactivate"): entry_id = unquote(path.split("/")[3]) self._json(store.reactivate_knowledge_entry(entry_id, str(body.get("reason", "")))) + elif path == "/api/submissions/decision-batch": + raw_ids = body.get("submission_ids", []) + if not isinstance(raw_ids, list): + raise ValueError("submission_ids must be a list") + self._json( + store.record_decision_batch( + [str(value) for value in raw_ids], + str(body.get("decision", "")), + str(body.get("memo", "")), + active_store(), + ) + ) + elif path == "/api/decision-memo-presets": + self._json( + store.save_decision_memo_preset( + str(body.get("label", "")), str(body.get("body", "")) + ) + ) + elif path.startswith("/api/decision-memo-presets/") and path.endswith("/delete"): + preset_id = unquote(path.split("/")[3]) + self._json(store.delete_decision_memo_preset(preset_id)) + elif path == "/api/admin/backup": + self._json(store.create_backup()) elif path == "/api/providers/emergency-disable": self._json(store.emergency_disable_external_providers()) else: @@ -288,7 +324,13 @@ def build_server( if not any(path.startswith(prefix) for prefix in _PROTECTED_PREFIXES): return True header = self.headers.get("Authorization", "") - if hmac.compare_digest(header, f"Bearer {auth_token}"): + # Compare as bytes: hmac.compare_digest raises TypeError on str inputs + # containing non-ASCII, and that exception escapes before any response + # is written — an unauthenticated remote client could hang the request + # by sending a single non-ASCII byte in the header. + if hmac.compare_digest( + header.encode("utf-8", "replace"), f"Bearer {auth_token}".encode("utf-8") + ): # First header-authenticated request mints an HttpOnly session # cookie so the browser can load /media (which cannot send an # Authorization header) without ever putting the token in a URL. @@ -299,7 +341,10 @@ def build_server( jar = SimpleCookie() jar.load(cookie_header) morsel = jar.get(_SESSION_COOKIE) - if morsel is not None and hmac.compare_digest(morsel.value, _session_tag(auth_token)): + if morsel is not None and hmac.compare_digest( + morsel.value.encode("utf-8", "replace"), + _session_tag(auth_token).encode("utf-8"), + ): # Cookie-authenticated state changes are CSRF-forgeable, so # require a same-origin Origin/Referer for them. if self.command in _STATE_CHANGING_METHODS and not self._origin_ok(): @@ -338,9 +383,18 @@ def build_server( if not length: return {} try: - return json.loads(self.rfile.read(length).decode("utf-8")) + parsed = json.loads(self.rfile.read(length).decode("utf-8")) except json.JSONDecodeError as exc: raise ValueError("invalid JSON body") from exc + except UnicodeDecodeError as exc: + raise ValueError("body must be UTF-8") from exc + # Every handler treats the body as a mapping (`payload.get(...)`). A + # valid-JSON list/string/null would sail past the decode and blow up + # with AttributeError inside the handler, which escapes the do_POST + # except-chain and closes the connection with no response at all. + if not isinstance(parsed, dict): + raise ValueError("JSON object body required") + return parsed def _maybe_set_session_cookie(self) -> None: if getattr(self, "_issue_session_cookie", False): @@ -374,7 +428,14 @@ def build_server( if not path.exists() or path.is_dir(): self._json({"error": "not found"}, HTTPStatus.NOT_FOUND) return - data = path.read_bytes() + # exists() -> read_bytes() is a TOCTOU window, and the file can also be + # locked (Windows), deleted mid-request, or unreadable. Answer with a + # real status instead of letting OSError close the connection silently. + try: + data = path.read_bytes() + except OSError: + self._json({"error": "not found"}, HTTPStatus.NOT_FOUND) + return content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" if content_type.startswith("text/") or content_type in {"application/javascript", "application/json"}: content_type = f"{content_type}; charset=utf-8" diff --git a/src/rights_filter/server/sqlite_store.py b/src/rights_filter/server/sqlite_store.py index d748a1d..81b8d02 100644 --- a/src/rights_filter/server/sqlite_store.py +++ b/src/rights_filter/server/sqlite_store.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging import os import shutil import sqlite3 @@ -38,10 +39,13 @@ from rights_filter.server.store_remote_fetch import ( _fetch_stylesheet_url_bytes, _fetch_url_bytes, ) +from rights_filter.server.store_backup import StoreBackupMixin +from rights_filter.server.store_duplicates import StoreDuplicatesMixin from rights_filter.server.store_enrichment import StoreEnrichmentMixin from rights_filter.server.store_operations import StoreOperationsMixin from rights_filter.server.store_persistence import StorePersistenceMixin from rights_filter.server.store_queue import StoreQueueMixin +from rights_filter.server.store_workflow import StoreWorkflowMixin from rights_filter.server.store_search_candidates import StoreSearchCandidatesMixin from rights_filter.server.store_schema import ( _ensure_constrained_schema, @@ -61,6 +65,13 @@ from rights_filter.server.store_serialization import ( _submission_search_hint_evidence, ) +logger = logging.getLogger(__name__) + +# Audit event name for a cross-queue submission id collision (same file stem in +# two imported folders). Kept as a constant so the "log it once" lookup and the +# write cannot drift apart. +_QUEUE_ID_CONFLICT_EVENT = "Submission id conflict" + class CopyrighterStore( StorePersistenceMixin, @@ -68,6 +79,9 @@ class CopyrighterStore( StoreSearchCandidatesMixin, StoreEnrichmentMixin, StoreOperationsMixin, + StoreDuplicatesMixin, + StoreWorkflowMixin, + StoreBackupMixin, ): def __init__( self, @@ -207,6 +221,10 @@ class CopyrighterStore( object_ref text not null default '', payload text not null check (json_valid(payload)) ); + create table if not exists decision_memo_presets ( + id text primary key, + payload text not null check (json_valid(payload)) + ); """ ) _ensure_typed_columns(conn) @@ -226,6 +244,48 @@ class CopyrighterStore( """ ) + def _submission_queue_owners(self, *, exclude_queue_id: str) -> dict[str, str]: + """Map submission id -> owning queue id, for queues other than the given one.""" + with self._connect() as conn: + rows = conn.execute( + """ + select id, queue_id from submissions + where queue_id is not null and queue_id != '' and queue_id != ? + """, + (exclude_queue_id,), + ).fetchall() + return {str(row["id"]): str(row["queue_id"]) for row in rows} + + def _note_queue_id_conflict( + self, submission_id: str, queue_id: str, owner_queue_id: str + ) -> None: + logger.warning( + "Submission id %r in queue %s is already owned by queue %s — skipping " + "import to avoid overwriting the existing decision.", + submission_id, + queue_id, + owner_queue_id, + ) + # Seeding runs on every reload/import/upload, so only record the conflict + # the first time it is seen for this submission instead of spamming the + # audit log on each pass. + with self._connect() as conn: + already_logged = conn.execute( + "select 1 from audit_events where event = ? and object_ref = ? limit 1", + (_QUEUE_ID_CONFLICT_EVENT, submission_id), + ).fetchone() + if already_logged is not None: + return + self.add_audit_event( + "rights.system", + _QUEUE_ID_CONFLICT_EVENT, + submission_id, + ( + f"동일 id가 다른 큐({owner_queue_id})에 이미 존재해 이 큐({queue_id})" + " 임포트를 건너뜀 — 파일명을 변경한 뒤 다시 불러오세요." + ), + ) + def seed_from_image_store(self, image_store: LocalSubmissionImageStore) -> int: # Serialize seeding so two concurrent reload/import/upload requests can't # both classify the same record as new and double-run analysis, external @@ -253,11 +313,25 @@ class CopyrighterStore( existing_submission_ids = { submission["id"] for submission in self._all("submissions", queue_id=queue_id) } - records = [ - record - for record in image_store.submission_records() - if record["id"] not in existing_submission_ids - ] + # The "is this new?" test above is queue-scoped, but the submissions PK is + # global and _put() upserts on it. A record whose id (the file stem) is + # already owned by a DIFFERENT queue would therefore be classified as new + # and overwrite that row: the other queue's decision/memo/query history + # resets to unreviewed, the row's queue_id moves, and the old evidence + # (linked by submission_id alone) stays attached and pollutes the new + # case's score. Skip the colliding record and make the conflict visible + # rather than destroying a decided submission. + foreign_queue_owners = self._submission_queue_owners(exclude_queue_id=queue_id) + records = [] + for record in image_store.submission_records(): + record_id = str(record["id"]) + if record_id in existing_submission_ids: + continue + owner_queue_id = foreign_queue_owners.get(record_id) + if owner_queue_id: + self._note_queue_id_conflict(record_id, queue_id, owner_queue_id) + continue + records.append(record) if not records: return 0 @@ -338,6 +412,10 @@ class CopyrighterStore( ) self._sync_submission_provider_state(queue_id=queue_id) self._normalize_saved_google_weak_labels_and_rescore(queue_id=queue_id) + # Persist submission fingerprints so duplicate / prior-case grouping can + # run from stored data. Idempotent: only submissions missing the field + # read their image again. + self.backfill_submission_fingerprints(image_store, queue_id=queue_id) with self._connect() as conn: conn.execute( """ @@ -393,8 +471,14 @@ class CopyrighterStore( queue_id = str(queue["id"]) if queue else None submissions = self._all("submissions", queue_id=queue_id) evidence = self._evidence_by_submission(queue_id=queue_id) + duplicates = self.duplicate_groups(queue_id=queue_id) for submission in submissions: submission["evidence"] = evidence.get(submission["id"], []) + # Only present when the case actually has a duplicate peer, so the + # GUI can treat presence of the key as "show the badge". + group = duplicates.get(str(submission["id"])) + if group: + submission["duplicateGroup"] = group return { "submissions": submissions, "submissionQueue": queue, @@ -405,6 +489,7 @@ class CopyrighterStore( "auditEvents": self.audit_events(), "coverageThresholds": dict(self.coverage_thresholds), "searchCoverage": self._search_coverage(submissions, evidence), + "decisionMemoPresets": self.decision_memo_presets(), } def _search_coverage(self, submissions: list[dict[str, Any]], evidence_by_submission: dict[str, list[dict[str, Any]]]) -> dict[str, Any]: @@ -459,8 +544,16 @@ class CopyrighterStore( # Attribute via _evidence_matches_provider (domain-aware) so Google # Custom Search (source "google", domain "google_custom_search") is # counted under google_search, not the Vision "google" bucket. + # Require an actual result, not merely a matching row: a submission + # whose only search evidence is a failure/skip has no coverage, and + # counting it inflated the coverage ratio into the "good" band while + # the operator still had nothing to review (mirrors the llm bucket). for search_provider in ("naver", "google", "google_search"): - if any(_evidence_matches_provider(item, search_provider) for item in evidence): + if any( + _evidence_matches_provider(item, search_provider) + and _provider_item_has_result(item) + for item in evidence + ): has_search_evidence = True provider_stats[search_provider]["evidenceSubmissions"] += 1 if has_search_evidence: diff --git a/src/rights_filter/server/store_backup.py b/src/rights_filter/server/store_backup.py new file mode 100644 index 0000000..a3108e7 --- /dev/null +++ b/src/rights_filter/server/store_backup.py @@ -0,0 +1,226 @@ +"""Online snapshot backup for CopyrighterStore, as a mixin. + +The whole operational record — decisions, knowledge base, audit trail — lives in +one SQLite file plus a few media directories, and an air-gapped deployment has no +cloud backup to fall back on. This produces a consistent snapshot while the +server keeps serving. + +WAL note: copying the .sqlite3 file while the server runs yields a torn snapshot +because recent commits still live in the -wal sidecar. sqlite3's online backup +API checkpoints into the destination instead, which is why the DB is copied +through `Connection.backup` rather than shutil. +""" + +from __future__ import annotations + +import shutil +import sqlite3 +import zipfile +from datetime import datetime +from pathlib import Path +from typing import Any + +# Store-owned media roots included in a snapshot, as (archive folder, attribute). +# Submission source images are handled separately: they live in whatever folder +# the active queue points at, which may be outside the data root. +_MEDIA_SOURCES = ( + ("knowledge", "knowledge_image_dir"), + ("collections", "collection_image_dir"), + ("face-crops", "face_crop_image_dir"), +) + +DEFAULT_BACKUP_RETENTION = 10 + + +class StoreBackupMixin: + def create_backup( + self, *, retention: int = DEFAULT_BACKUP_RETENTION + ) -> dict[str, Any]: + """Write a timestamped snapshot zip and prune older ones.""" + backup_dir = Path(self.db_path).resolve().parent / "backups" + backup_dir.mkdir(parents=True, exist_ok=True) + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + # Two snapshots inside the same second would otherwise overwrite each + # other, silently destroying the earlier restore point. + archive_path = backup_dir / f"copyrighter-snapshot-{stamp}.zip" + suffix = 1 + while archive_path.exists(): + suffix += 1 + archive_path = backup_dir / f"copyrighter-snapshot-{stamp}-{suffix}.zip" + staged_db = backup_dir / f".snapshot-{archive_path.stem}.sqlite3" + + try: + self._snapshot_database(staged_db) + with zipfile.ZipFile( + archive_path, "w", compression=zipfile.ZIP_DEFLATED + ) as archive: + archive.write(staged_db, arcname="copyrighter.sqlite3") + media_counts = self._archive_media(archive) + finally: + staged_db.unlink(missing_ok=True) + + pruned = _prune_old_backups(backup_dir, retention) + size_bytes = archive_path.stat().st_size + self.add_audit_event( + "rights.ops", + "Backup snapshot created", + archive_path.name, + f"{size_bytes} bytes · media {media_counts} · pruned {pruned}", + ) + return { + "file": archive_path.name, + "path": str(archive_path), + "sizeBytes": size_bytes, + "createdAt": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "mediaFiles": media_counts, + "prunedFiles": pruned, + } + + def backups(self) -> list[dict[str, Any]]: + backup_dir = Path(self.db_path).resolve().parent / "backups" + if not backup_dir.exists(): + return [] + entries = [] + for path in sorted(backup_dir.glob("copyrighter-snapshot-*.zip"), reverse=True): + stat = path.stat() + entries.append( + { + "file": path.name, + "sizeBytes": stat.st_size, + "createdAt": datetime.fromtimestamp(stat.st_mtime).strftime( + "%Y-%m-%d %H:%M:%S" + ), + } + ) + return entries + + def _snapshot_database(self, destination: Path) -> None: + # Connection.backup copies a transactionally consistent image, including + # anything still sitting in the WAL, without blocking writers. + # + # sqlite3's context manager only commits/rolls back — it does NOT close. + # Both handles must be closed explicitly or the staged file stays locked + # and cannot be zipped/removed on Windows. + source = self._connect() + try: + target = sqlite3.connect(destination) + try: + source.backup(target) + finally: + target.close() + finally: + source.close() + + def _archive_media(self, archive: zipfile.ZipFile) -> int: + roots: list[tuple[str, Path]] = [] + for arc_folder, attribute in _MEDIA_SOURCES: + root = getattr(self, attribute, None) + if root is not None: + roots.append((arc_folder, Path(root))) + + submission_root = self._active_submission_root() + if submission_root is not None: + roots.append(("submissions", submission_root)) + + written = 0 + for arc_folder, root_path in roots: + if not root_path.exists(): + continue + for path in sorted(root_path.rglob("*")): + if not path.is_file(): + continue + archive.write( + path, + arcname=f"media/{arc_folder}/{path.relative_to(root_path).as_posix()}", + ) + written += 1 + return written + + def _active_submission_root(self) -> Path | None: + # Best-effort: a store with no configured queue still produces a valid + # database snapshot, just without submission source images. + try: + queue = self.bootstrap_active_queue() + except Exception: + return None + if not queue or not queue.get("folderPath"): + return None + return Path(str(queue["folderPath"])) + + +def restore_backup(archive_path: Path | str, target_db: Path | str) -> dict[str, Any]: + """Restore a snapshot over a STOPPED server's data directory. + + Offline on purpose: replacing the database under a live server would leave + open connections reading a file that no longer exists. + """ + archive = Path(archive_path) + db_path = Path(target_db) + if not archive.exists(): + raise ValueError(f"backup archive not found: {archive}") + + data_root = db_path.resolve().parent + with zipfile.ZipFile(archive) as bundle: + names = set(bundle.namelist()) + if "copyrighter.sqlite3" not in names: + raise ValueError("archive does not contain copyrighter.sqlite3") + + if db_path.exists(): + # Keep the pre-restore database next to it; an operator who restores + # the wrong snapshot would otherwise have no way back. + rescue = db_path.with_suffix( + f".sqlite3.pre-restore-{datetime.now().strftime('%Y%m%d-%H%M%S')}" + ) + shutil.copy2(db_path, rescue) + # Stale sidecars would be replayed on top of the restored file. + for sidecar in (f"{db_path}-wal", f"{db_path}-shm"): + Path(sidecar).unlink(missing_ok=True) + + db_path.parent.mkdir(parents=True, exist_ok=True) + with bundle.open("copyrighter.sqlite3") as source, open(db_path, "wb") as target: + shutil.copyfileobj(source, target) + + restored_media = 0 + for name in sorted(names): + if not name.startswith("media/") or name.endswith("/"): + continue + relative = Path(name).relative_to("media") + destination = data_root / _MEDIA_RESTORE_DIRS.get( + relative.parts[0], relative.parts[0] + ) + destination = destination.joinpath(*relative.parts[1:]) + destination.parent.mkdir(parents=True, exist_ok=True) + with bundle.open(name) as source, open(destination, "wb") as target: + shutil.copyfileobj(source, target) + restored_media += 1 + + return {"database": str(db_path), "mediaFiles": restored_media} + + +# Archive folder -> path relative to the data root, so a restore lands where the +# running server expects to find each media kind. +_MEDIA_RESTORE_DIRS = { + "knowledge": "knowledge/images", + "collections": "collections/images", + "face-crops": "face-crops", + # Submission images restore under the data root's default submissions folder. + # If the queue pointed elsewhere, re-import that folder after restoring. + "submissions": "submissions", +} + + +def _prune_old_backups(backup_dir: Path, retention: int) -> int: + keep = max(1, int(retention)) + snapshots = sorted( + backup_dir.glob("copyrighter-snapshot-*.zip"), + key=lambda path: path.stat().st_mtime, + reverse=True, + ) + removed = 0 + for path in snapshots[keep:]: + try: + path.unlink() + removed += 1 + except OSError: + continue + return removed diff --git a/src/rights_filter/server/store_constants.py b/src/rights_filter/server/store_constants.py index 17f4082..1642100 100644 --- a/src/rights_filter/server/store_constants.py +++ b/src/rights_filter/server/store_constants.py @@ -23,6 +23,7 @@ STORE_TABLES = { "corrections", "audit_events", "submission_queues", + "decision_memo_presets", } RISK_BANDS = ("low", "medium", "high", "failed", "pending") DECISION_STATUSES = ("unreviewed", "held", "rejected", "approved", "corrected") diff --git a/src/rights_filter/server/store_duplicates.py b/src/rights_filter/server/store_duplicates.py new file mode 100644 index 0000000..96f64d6 --- /dev/null +++ b/src/rights_filter/server/store_duplicates.py @@ -0,0 +1,161 @@ +"""Submission-to-submission duplicate / prior-case index, as a mixin. + +pHash was only ever compared against the knowledge base and against search-result +images — never between submissions — so the same picture arriving twice was +reviewed twice from scratch, and a resubmission of an already-rejected image was +invisible unless a watchlist entry happened to exist. + +The fingerprints are already computed during analysis but the evidence payload +whitelist drops them, so this module backfills `fingerprints` onto the submission +record (idempotent, image read only when missing) and groups from there. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from rights_filter.analysis.fingerprints import FingerprintService +from rights_filter.server.image_store import LocalSubmissionImageStore + +logger = logging.getLogger(__name__) + +# Hamming-distance similarity at/above which two submissions are treated as the +# same picture. Deliberately stricter than the knowledge-base match threshold +# (0.9): this drives "these are duplicates, handle them together", so a false +# grouping is more costly than a missed one. +DUPLICATE_SIMILARITY_THRESHOLD = 0.97 + + +class StoreDuplicatesMixin: + def backfill_submission_fingerprints( + self, image_store: LocalSubmissionImageStore, queue_id: str | None = None + ) -> int: + """Persist exact/perceptual fingerprints on submissions missing them.""" + service = FingerprintService() + updated = 0 + for submission in self._all("submissions", queue_id=queue_id): + if submission.get("fingerprints"): + continue + submission_id = str(submission["id"]) + fingerprints = _fingerprints_for_submission( + service, image_store, submission_id + ) + if fingerprints is None: + continue + submission["fingerprints"] = fingerprints + self._put("submissions", submission_id, submission) + updated += 1 + return updated + + def duplicate_groups(self, queue_id: str | None = None) -> dict[str, dict[str, Any]]: + """Map submission id -> duplicate-group summary for the given queue. + + Only submissions that have at least one peer appear in the result. + """ + submissions = [ + item for item in self._all("submissions", queue_id=queue_id) + if _perceptual_of(item) or _exact_of(item) + ] + groups = _cluster(submissions) + + summary: dict[str, dict[str, Any]] = {} + for group in groups: + if len(group) < 2: + continue + for submission in group: + peers = [other for other in group if other["id"] != submission["id"]] + decided_peers = [ + { + "submissionId": str(peer["id"]), + "title": str(peer.get("title", "")), + "decision": str(peer.get("decisionStatus", "unreviewed")), + } + for peer in peers + if str(peer.get("decisionStatus", "unreviewed")) != "unreviewed" + ] + summary[str(submission["id"])] = { + "size": len(group), + "peerIds": [str(peer["id"]) for peer in peers], + "priorDecisions": decided_peers, + "conflicting": _has_conflicting_decisions(group), + } + return summary + + +def _fingerprints_for_submission( + service: FingerprintService, + image_store: LocalSubmissionImageStore, + submission_id: str, +) -> dict[str, str] | None: + try: + payload = image_store.image_payload(submission_id) + except (KeyError, OSError, ValueError): + # A submission whose file is gone simply has no fingerprint; the queue + # prune handles its removal separately. + return None + try: + fingerprints = service.fingerprints_for(payload.content) + except Exception: + logger.warning("Could not fingerprint submission %s", submission_id) + return None + return {"exact": fingerprints.exact, "perceptual": fingerprints.perceptual} + + +def _exact_of(submission: dict[str, Any]) -> str: + return str((submission.get("fingerprints") or {}).get("exact", "")) + + +def _perceptual_of(submission: dict[str, Any]) -> str: + return str((submission.get("fingerprints") or {}).get("perceptual", "")) + + +def _cluster(submissions: list[dict[str, Any]]) -> list[list[dict[str, Any]]]: + """Group submissions by exact hash, then merge near-identical pHash groups. + + Queues hold hundreds of cases, so the O(n^2) perceptual pass is cheap enough + and avoids the accuracy loss of hash bucketing. + """ + service = FingerprintService() + groups: list[list[dict[str, Any]]] = [] + by_exact: dict[str, list[dict[str, Any]]] = {} + + for submission in submissions: + exact = _exact_of(submission) + if exact: + by_exact.setdefault(exact, []).append(submission) + else: + groups.append([submission]) + groups.extend(by_exact.values()) + + merged: list[list[dict[str, Any]]] = [] + for group in groups: + representative = _perceptual_of(group[0]) + target = None + if representative: + for candidate in merged: + candidate_hash = _perceptual_of(candidate[0]) + if not candidate_hash: + continue + if ( + service.similarity(representative, candidate_hash) + >= DUPLICATE_SIMILARITY_THRESHOLD + ): + target = candidate + break + if target is None: + merged.append(list(group)) + else: + target.extend(group) + return merged + + +def _has_conflicting_decisions(group: list[dict[str, Any]]) -> bool: + decisions = { + str(item.get("decisionStatus", "unreviewed")) + for item in group + if str(item.get("decisionStatus", "unreviewed")) != "unreviewed" + } + # The same image approved in one case and rejected in another is a review + # consistency problem the operator should see before deciding again. + return "approved" in decisions and bool(decisions & {"rejected", "held"}) diff --git a/src/rights_filter/server/store_workflow.py b/src/rights_filter/server/store_workflow.py new file mode 100644 index 0000000..39b2edf --- /dev/null +++ b/src/rights_filter/server/store_workflow.py @@ -0,0 +1,284 @@ +"""Operator throughput features for CopyrighterStore, as a mixin. + +Batch decisions, reusable decision-memo presets, and decision export. Mixed into +CopyrighterStore; relies on persistence + operations methods and self.* +attributes provided by the host class. +""" + +from __future__ import annotations + +import csv +import io +from typing import Any + +from rights_filter.server.image_store import LocalSubmissionImageStore +from rights_filter.server.store_serialization import _now_label, _stable_id + +# A batch is an operator convenience, not an import path: keep it small enough +# that one accidental "select all" cannot rewrite the whole queue in one click. +MAX_BATCH_DECISIONS = 200 + +# Fields the applicant-facing export may contain. Anything not listed here — +# risk score, evidence, provider state, internal memos beyond the decision +# reason — must never cross this boundary. +APPLICANT_EXPORT_FIELDS = ("submissionId", "title", "decision", "applicantStatus", "memo") + +INTERNAL_EXPORT_FIELDS = ( + "submissionId", + "title", + "decision", + "applicantStatus", + "riskScore", + "riskBand", + "memo", + "decidedAt", + "evidenceUsed", + "evidenceUrls", +) + + +class StoreWorkflowMixin: + # ---- batch decisions ------------------------------------------------ + + def record_decision_batch( + self, + submission_ids: list[str], + decision: str, + memo: str = "", + image_store: LocalSubmissionImageStore | None = None, + ) -> dict[str, Any]: + """Apply one decision to many submissions, reporting per-case outcomes. + + Each case goes through record_decision so the per-case transaction, audit + event and watchlist side effects stay identical to a single decision. + One failure does not abort the rest — the caller gets an explicit list of + what succeeded and what did not. + """ + if decision not in {"approved", "held", "rejected"}: + raise ValueError("unsupported decision") + unique_ids = list(dict.fromkeys(str(value) for value in submission_ids if str(value))) + if not unique_ids: + raise ValueError("no submissions selected") + if len(unique_ids) > MAX_BATCH_DECISIONS: + raise ValueError( + f"batch too large: {len(unique_ids)} > {MAX_BATCH_DECISIONS}" + ) + if decision == "rejected" and not memo.strip(): + raise ValueError("reject decision requires memo") + + succeeded: list[str] = [] + failed: list[dict[str, str]] = [] + for submission_id in unique_ids: + try: + self.record_decision(submission_id, decision, memo, image_store) + succeeded.append(submission_id) + except KeyError: + # str(KeyError) is just the repr'd key — useless in the UI. + failed.append( + {"submissionId": submission_id, "error": "제출물을 찾을 수 없습니다"} + ) + except ValueError as exc: + failed.append({"submissionId": submission_id, "error": str(exc)}) + + self.add_audit_event( + "rights.ops", + "Operator batch decision", + f"{decision} x{len(succeeded)}", + f"{len(succeeded)} applied, {len(failed)} failed · {memo or 'memo optional'}", + ) + payload = self.bootstrap() + payload["batchDecision"] = { + "decision": decision, + "applied": succeeded, + "failed": failed, + } + return payload + + # ---- decision memo presets ------------------------------------------ + + def decision_memo_presets(self) -> list[dict[str, Any]]: + presets = self._all("decision_memo_presets") + return sorted(presets, key=lambda item: str(item.get("label", ""))) + + def save_decision_memo_preset(self, label: str, body: str) -> list[dict[str, Any]]: + clean_label = str(label or "").strip() + clean_body = str(body or "").strip() + if not clean_label: + raise ValueError("preset label is required") + if not clean_body: + raise ValueError("preset body is required") + if len(clean_label) > 40: + raise ValueError("preset label must be 40 characters or fewer") + if len(clean_body) > 500: + raise ValueError("preset body must be 500 characters or fewer") + + # Key by label so re-saving the same label edits it instead of stacking + # near-duplicate chips in the operator's decision pane. + preset_id = _stable_id("preset", clean_label) + self._put( + "decision_memo_presets", + preset_id, + { + "id": preset_id, + "label": clean_label, + "body": clean_body, + "updatedAt": _now_label(), + }, + ) + self.add_audit_event( + "rights.ops", "Decision memo preset saved", preset_id, clean_label + ) + return self.decision_memo_presets() + + def delete_decision_memo_preset(self, preset_id: str) -> list[dict[str, Any]]: + with self._connect() as conn: + cursor = conn.execute( + "delete from decision_memo_presets where id = ?", (str(preset_id),) + ) + if not cursor.rowcount: + raise KeyError(preset_id) + self.add_audit_event( + "rights.ops", "Decision memo preset deleted", str(preset_id), "removed" + ) + return self.decision_memo_presets() + + # ---- decision export ------------------------------------------------- + + def export_decisions( + self, + *, + audience: str = "internal", + status: str = "", + since: str = "", + ) -> dict[str, Any]: + """Collect decided cases for hand-off. + + `audience="applicant"` restricts the payload to the fields that may leave + the review system: no risk score, no evidence, no provider state. + """ + if audience not in {"internal", "applicant"}: + raise ValueError("audience must be 'internal' or 'applicant'") + wanted_status = str(status or "").strip() + if wanted_status and wanted_status not in {"approved", "held", "rejected", "corrected"}: + raise ValueError("unsupported decision status filter") + + decided_at = self._decision_timestamps() + since_text = str(since or "").strip() + rows: list[dict[str, Any]] = [] + for submission in self._all("submissions"): + decision = str(submission.get("decisionStatus", "unreviewed")) + if decision == "unreviewed": + continue + if wanted_status and decision != wanted_status: + continue + submission_id = str(submission.get("id", "")) + timestamp = decided_at.get(submission_id, "") + if since_text and timestamp and timestamp < since_text: + continue + rows.append( + self._export_row(submission, decision, timestamp, audience=audience) + ) + + rows.sort(key=lambda row: str(row.get("decidedAt", "")), reverse=True) + fields = ( + APPLICANT_EXPORT_FIELDS if audience == "applicant" else INTERNAL_EXPORT_FIELDS + ) + self.add_audit_event( + "rights.ops", + "Decisions exported", + audience, + f"{len(rows)} case(s) · status={wanted_status or 'all'} · since={since_text or 'all'}", + ) + return { + "audience": audience, + "fields": list(fields), + "rows": rows, + "csv": _rows_to_csv(rows, fields), + } + + def _export_row( + self, + submission: dict[str, Any], + decision: str, + decided_at: str, + *, + audience: str, + ) -> dict[str, Any]: + submission_id = str(submission.get("id", "")) + memo = self._latest_decision_memo(submission_id) + row = { + "submissionId": submission_id, + "title": str(submission.get("title", "")), + "decision": decision, + "applicantStatus": str(submission.get("applicantStatus", "")), + "memo": memo, + } + if audience == "applicant": + return row + + used = [ + item + for item in self._evidence_for_submission(submission_id) + if str(item.get("operatorStatus", "")) == "used_for_judgment" + ] + row.update( + { + "riskScore": submission.get("riskScore", 0), + "riskBand": str(submission.get("riskBand", "")), + "decidedAt": decided_at, + "evidenceUsed": " | ".join(str(item.get("title", "")) for item in used), + "evidenceUrls": " | ".join( + str(item.get("url", "")) for item in used if item.get("url") + ), + } + ) + return row + + def _decision_events(self) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute( + """ + select object_ref, timestamp, payload from audit_events + where event = 'Operator decision created' + order by id asc + """ + ).fetchall() + return [ + { + "object_ref": str(row["object_ref"]), + "timestamp": str(row["timestamp"]), + "payload": row["payload"], + } + for row in rows + ] + + def _decision_timestamps(self) -> dict[str, str]: + # Submissions carry no decided-at field; the audit trail is the only + # record of when the operator decided, so read the latest event per case. + return {event["object_ref"]: event["timestamp"] for event in self._decision_events()} + + def _latest_decision_memo(self, submission_id: str) -> str: + import json + + memo = "" + for event in self._decision_events(): + if event["object_ref"] != submission_id: + continue + try: + change = str(json.loads(event["payload"]).get("change", "")) + except (ValueError, TypeError): + continue + # Audit change text is " · ". + _, separator, tail = change.partition(" · ") + if separator and tail and tail != "memo optional": + memo = tail + return memo + + +def _rows_to_csv(rows: list[dict[str, Any]], fields: tuple[str, ...]) -> str: + buffer = io.StringIO() + writer = csv.DictWriter(buffer, fieldnames=list(fields), extrasaction="ignore") + writer.writeheader() + for row in rows: + writer.writerow(row) + return buffer.getvalue() diff --git a/tests/operator_gui/test_static_workbench.py b/tests/operator_gui/test_static_workbench.py index a996c47..95be6fb 100644 --- a/tests/operator_gui/test_static_workbench.py +++ b/tests/operator_gui/test_static_workbench.py @@ -128,8 +128,14 @@ def test_workbench_shell_exposes_all_internal_operator_views(): assert "추천 쿼리로 외부 검색 결과를 보강합니다." in html assert "기준 DB에 반영합니다." in html assert ".operator-workflow" in styles - assert "bulk-approve" not in html.lower() - assert "bulk-reject" not in html.lower() + # 2026-07-24: the v1 "no bulk approve/reject" restriction was lifted (see + # docs/superpowers/specs/2026-05-25-image-rights-operator-gui-design.md). + # Batch decisions are allowed, but only with the guardrails that made lifting + # it acceptable — assert those are present rather than that the controls are. + assert 'id="bulk-approve"' in html + assert 'id="bulk-reject"' in html + assert 'id="bulk-decision-count"' in html # selection size is always visible + assert 'id="bulk-decision-status"' in html # per-case outcome is reported back assert 'data-view="evidence"' not in html assert 'id="evidence-view"' not in html diff --git a/tests/rights_filter/server/test_store_workflow.py b/tests/rights_filter/server/test_store_workflow.py new file mode 100644 index 0000000..d43fb76 --- /dev/null +++ b/tests/rights_filter/server/test_store_workflow.py @@ -0,0 +1,255 @@ +from pathlib import Path + +import pytest + +from rights_filter.integrations.env_clients import build_provider_runtime +from rights_filter.server.sqlite_store import CopyrighterStore +from rights_filter.server.store_backup import restore_backup +from rights_filter.server.store_workflow import MAX_BATCH_DECISIONS + +from tests.rights_filter.server.test_sqlite_store import _image_store, FakeJsonTransport + + +def _photo_store(root: Path, *, duplicate: bool = False): + """Two visually distinct PNGs (ids = file stems), optionally plus a byte copy. + + Real raster images so the fingerprint/duplicate paths behave as in production; + the shared SVG fixture cannot be perceptually hashed. + """ + from io import BytesIO + + from PIL import Image, ImageDraw + + from rights_filter.server.image_store import LocalSubmissionImageStore + + image_dir = root / "images" + image_dir.mkdir(parents=True, exist_ok=True) + for name, color in (("alpha", (210, 40, 40)), ("beta", (30, 90, 200))): + canvas = Image.new("RGB", (64, 48), color=color) + draw = ImageDraw.Draw(canvas) + # Vary the pixels so the average hash is informative, not degenerate. + draw.rectangle([4, 4, 30, 30], fill=(255, 255, 255)) + draw.ellipse([32, 10, 60, 40], fill=(10, 10, 10)) + if name == "beta": + draw.line([0, 0, 63, 47], fill=(250, 250, 0), width=5) + buffer = BytesIO() + canvas.save(buffer, format="PNG") + (image_dir / f"{name}.png").write_bytes(buffer.getvalue()) + + if duplicate: + (image_dir / "alpha-copy.png").write_bytes((image_dir / "alpha.png").read_bytes()) + + return LocalSubmissionImageStore(root) + + +def _store(tmp_path: Path, *, duplicate: bool = False) -> CopyrighterStore: + store = CopyrighterStore( + tmp_path / "copyrighter.sqlite3", + provider_runtime=build_provider_runtime(env={}, transport=FakeJsonTransport({})), + ) + store.initialize() + store.seed_from_image_store(_photo_store(tmp_path / "submissions", duplicate=duplicate)) + return store + + +def _first_ids(store: CopyrighterStore, count: int) -> list[str]: + return [str(item["id"]) for item in store.bootstrap()["submissions"][:count]] + + +# --- batch decisions --------------------------------------------------------- + + +def test_batch_decision_applies_to_every_selected_submission(tmp_path: Path): + store = _store(tmp_path) + ids = _first_ids(store, 2) + + payload = store.record_decision_batch(ids, "approved") + + assert payload["batchDecision"]["applied"] == ids + assert payload["batchDecision"]["failed"] == [] + decided = { + str(item["id"]): item["decisionStatus"] for item in payload["submissions"] + } + assert all(decided[submission_id] == "approved" for submission_id in ids) + + +def test_batch_decision_reports_per_case_failure_without_aborting_the_rest(tmp_path: Path): + store = _store(tmp_path) + ids = _first_ids(store, 1) + + payload = store.record_decision_batch([*ids, "SUB-DOES-NOT-EXIST"], "approved") + + assert payload["batchDecision"]["applied"] == ids + assert [item["submissionId"] for item in payload["batchDecision"]["failed"]] == [ + "SUB-DOES-NOT-EXIST" + ] + + +def test_batch_reject_requires_memo(tmp_path: Path): + store = _store(tmp_path) + ids = _first_ids(store, 1) + + with pytest.raises(ValueError, match="memo"): + store.record_decision_batch(ids, "rejected", " ") + + +def test_batch_decision_rejects_oversized_selection(tmp_path: Path): + store = _store(tmp_path) + + with pytest.raises(ValueError, match="batch too large"): + store.record_decision_batch( + [f"SUB-{index}" for index in range(MAX_BATCH_DECISIONS + 1)], "approved" + ) + + +# --- memo presets ------------------------------------------------------------ + + +def test_memo_preset_save_is_upsert_by_label(tmp_path: Path): + store = _store(tmp_path) + + store.save_decision_memo_preset("연예인 초상", "연예인 초상이 포함되어 있습니다.") + presets = store.save_decision_memo_preset("연예인 초상", "수정된 문구") + + assert len(presets) == 1 + assert presets[0]["body"] == "수정된 문구" + assert store.bootstrap()["decisionMemoPresets"][0]["label"] == "연예인 초상" + + +def test_memo_preset_requires_label_and_body(tmp_path: Path): + store = _store(tmp_path) + + with pytest.raises(ValueError): + store.save_decision_memo_preset("", "body") + with pytest.raises(ValueError): + store.save_decision_memo_preset("label", " ") + + +def test_memo_preset_delete_removes_entry(tmp_path: Path): + store = _store(tmp_path) + presets = store.save_decision_memo_preset("방송 캡처", "방송 화면 캡처입니다.") + + remaining = store.delete_decision_memo_preset(presets[0]["id"]) + + assert remaining == [] + + +# --- decision export --------------------------------------------------------- + + +def test_export_skips_undecided_cases_and_carries_the_memo(tmp_path: Path): + store = _store(tmp_path) + ids = _first_ids(store, 1) + store.record_decision(ids[0], "rejected", "연예인 초상 포함") + + export = store.export_decisions(audience="internal") + + assert [row["submissionId"] for row in export["rows"]] == ids + assert export["rows"][0]["decision"] == "rejected" + assert export["rows"][0]["memo"] == "연예인 초상 포함" + assert "riskScore" in export["rows"][0] + + +def test_applicant_export_never_exposes_internal_signals(tmp_path: Path): + store = _store(tmp_path) + ids = _first_ids(store, 1) + store.record_decision(ids[0], "approved") + + export = store.export_decisions(audience="applicant") + + row = export["rows"][0] + for forbidden in ("riskScore", "riskBand", "evidenceUsed", "evidenceUrls"): + assert forbidden not in row + assert "riskScore" not in export["csv"] + + +def test_export_status_filter_and_bad_audience(tmp_path: Path): + store = _store(tmp_path) + ids = _first_ids(store, 2) + store.record_decision(ids[0], "approved") + store.record_decision(ids[1], "held") + + approved = store.export_decisions(status="approved") + + assert [row["submissionId"] for row in approved["rows"]] == [ids[0]] + with pytest.raises(ValueError, match="audience"): + store.export_decisions(audience="public") + + +# --- duplicate grouping ------------------------------------------------------ + + +def test_identical_images_are_grouped_with_prior_decision(tmp_path: Path): + store = _store(tmp_path, duplicate=True) + store.record_decision("alpha", "rejected", "원본 반려") + + groups = store.duplicate_groups() + + assert "alpha-copy" in groups + assert "alpha" in groups["alpha-copy"]["peerIds"] + assert groups["alpha-copy"]["priorDecisions"][0]["decision"] == "rejected" + # A distinct image must not be pulled into the group. + assert "beta" not in groups + + +def test_duplicate_group_flags_conflicting_decisions(tmp_path: Path): + store = _store(tmp_path, duplicate=True) + store.record_decision("alpha", "approved") + store.record_decision("alpha-copy", "rejected", "같은 이미지인데 반려") + + groups = store.duplicate_groups() + + assert groups["alpha"]["conflicting"] is True + + +def test_unique_images_get_no_duplicate_group(tmp_path: Path): + store = _store(tmp_path) + + submissions = store.bootstrap()["submissions"] + + assert all("duplicateGroup" not in item for item in submissions) + + +# --- backup / restore -------------------------------------------------------- + + +def test_backup_snapshot_round_trips_decisions(tmp_path: Path): + store = _store(tmp_path) + ids = _first_ids(store, 1) + store.record_decision(ids[0], "approved") + + snapshot = store.create_backup() + + assert snapshot["file"].startswith("copyrighter-snapshot-") + assert snapshot["sizeBytes"] > 0 + assert store.backups()[0]["file"] == snapshot["file"] + + # Simulate loss, then restore into a fresh database path. + restored_db = tmp_path / "restored" / "copyrighter.sqlite3" + result = restore_backup(snapshot["path"], restored_db) + + assert result["database"] == str(restored_db) + reopened = CopyrighterStore( + restored_db, + provider_runtime=build_provider_runtime(env={}, transport=FakeJsonTransport({})), + ) + reopened.initialize() + decided = { + str(item["id"]): item["decisionStatus"] for item in reopened.bootstrap()["submissions"] + } + assert decided[ids[0]] == "approved" + + +def test_backup_retention_prunes_older_snapshots(tmp_path: Path): + store = _store(tmp_path) + + created = [store.create_backup(retention=2)["file"] for _ in range(3)] + + # Distinct files even within one second, and only the newest two survive. + assert len(set(created)) == 3 + assert len(store.backups()) == 2 + + +def test_restore_rejects_missing_archive(tmp_path: Path): + with pytest.raises(ValueError, match="not found"): + restore_backup(tmp_path / "nope.zip", tmp_path / "db.sqlite3") diff --git a/web/operator-gui/app.js b/web/operator-gui/app.js index 9869396..3a678dc 100644 --- a/web/operator-gui/app.js +++ b/web/operator-gui/app.js @@ -65,6 +65,10 @@ const auditEvents = []; +const decisionMemoPresets = []; + + + const state = { currentView: "queue", @@ -113,14 +117,116 @@ const viewNames = new Set(["queue", "workbench", "knowledge", "providers", "audi -async function apiJson(path, options = {}) { +// Bearer token for a server started with COPYRIGHTER_AUTH_TOKEN. Held in memory +// only (never localStorage/sessionStorage): the first authenticated request makes +// the server mint an HttpOnly session cookie, which is what subsequently +// authorizes /media requests that cannot carry a header. + +let authToken = ""; + +let pendingAuthPrompt = null; + + + +function promptForAuthToken(message = "") { + + const gate = document.getElementById("auth-gate"); + + const form = document.getElementById("auth-gate-form"); + + const input = document.getElementById("auth-gate-input"); + + const error = document.getElementById("auth-gate-error"); + + if (!gate || !form || !input) { + + return Promise.resolve(""); + + } + + // Collapse concurrent 401s onto one prompt so a burst of parallel requests + // cannot stack multiple dialogs. + + if (pendingAuthPrompt) { + + return pendingAuthPrompt; + + } + + if (error) { + + error.hidden = !message; + + error.textContent = message || ""; + + } + + gate.hidden = false; + + input.value = ""; + + input.focus(); + + pendingAuthPrompt = new Promise((resolve) => { + + const onSubmit = (event) => { + + event.preventDefault(); + + const value = input.value.trim(); + + if (!value) { + + return; + + } + + form.removeEventListener("submit", onSubmit); + + gate.hidden = true; + + input.value = ""; + + pendingAuthPrompt = null; + + resolve(value); + + }; + + form.addEventListener("submit", onSubmit); + + }); + + return pendingAuthPrompt; + +} + + + +function authHeaders(extra) { + + const headers = { "Content-Type": "application/json", ...(extra || {}) }; + + if (authToken) { + + headers.Authorization = `Bearer ${authToken}`; + + } + + return headers; + +} + + + +async function apiJson(path, options = {}, allowAuthRetry = true) { const response = await fetch(path, { - headers: { "Content-Type": "application/json", ...(options.headers || {}) }, - ...options, + headers: authHeaders(options.headers), + }); const text = await response.text(); @@ -141,6 +247,27 @@ async function apiJson(path, options = {}) { } + if (response.status === 401 && allowAuthRetry) { + + // Ask for the token, then replay this request once. A second 401 falls + // through to the normal error path instead of looping. + + const token = await promptForAuthToken( + + authToken ? "토큰이 올바르지 않습니다. 다시 입력하세요." : "", + + ); + + if (token) { + + authToken = token; + + return apiJson(path, options, false); + + } + + } + if (!response.ok) { const message = (payload && payload.error) || `API 요청 실패: ${response.status}`; @@ -222,6 +349,8 @@ function applyBootstrap(payload) { replaceCollection(auditEvents, payload.auditEvents); + replaceCollection(decisionMemoPresets, payload.decisionMemoPresets || []); + if (!submissions.some((item) => item.id === state.selectedCaseId)) { state.selectedCaseId = submissions[0]?.id || ""; @@ -1044,13 +1173,25 @@ async function executeSuggestedQueries(queries) { method: "POST", body: JSON.stringify({ submission_id: submission.id, provider: "naver", query }), }); - const historyEntry = (review.queryHistory || [])[0]; - const resultCount = historyEntry && historyEntry.query === query ? Number(historyEntry.count || 0) : 0; - const skipped = - resultCount === 0 - ? (review.evidence || []).find((item) => item.source === "failure" && item.query === query) - : null; - results.push(skipped ? `"${query}" ${formatReason(skipped.title || "건너뜀")}` : `"${query}" 완료(근거 ${resultCount}건)`); + // queryHistory.count is the number of evidence rows the search produced, + // which includes the skip/failure/"no results" markers — so a query that + // was refused still reported count>=1 and was announced as "완료(근거 1건)" + // while the skip branch below could never run. Count real evidence here + // and let a failure row speak for itself. + const matching = (review.evidence || []).filter((item) => item.query === query); + const skipped = matching.find((item) => item.source === "failure"); + const resultCount = matching.filter( + (item) => + item.source !== "failure" && + !/returned no results/i.test(String(item.title || "")), + ).length; + if (skipped) { + results.push(`"${query}" ${formatReason(skipped.title || "건너뜀")}`); + } else if (resultCount === 0) { + results.push(`"${query}" 결과 없음`); + } else { + results.push(`"${query}" 완료(근거 ${resultCount}건)`); + } } catch (errorValue) { results.push(`"${query}" 실패: ${errorValue.message}`); } @@ -1233,6 +1374,8 @@ function renderQueue() { + ${duplicateBadgeHtml(submission)} + @@ -1315,6 +1458,11 @@ function renderNoSelectedCase() { document.getElementById("evidence-groups").innerHTML = `
API 연결 실패 또는 제출 이미지 없음 상태입니다.
`; document.getElementById("recommendation-box").innerHTML = `실제 API 데이터가 로드될 때까지 판정하지 않습니다.`; document.getElementById("derived-preview").innerHTML = `자동 후보 생성 없음`; + // Clear the evidence-gap recommendations too: they belong to the case that was + // just deselected, and leaving them rendered invited running suggested queries + // against nothing. + const nextActions = document.getElementById("evidence-next-actions"); + if (nextActions) nextActions.innerHTML = ""; } @@ -2210,6 +2358,12 @@ function renderAll() { renderAuditLog(); + renderMemoPresets(); + + renderBulkDecisionCount(); + + renderReviewProgress(); + } @@ -2226,11 +2380,17 @@ function switchView(view, updateHash = true) { if (view === "case" || view === "evidence") view = "workbench"; + let knowledgeTabToApply = ""; + if (view === "corrections") { view = "knowledge"; - state.knowledgeTab = "corrections"; + // Setting state alone left the visible panel on whatever knowledge tab was + // last shown — the tab DOM is switched by switchKnowledgeTab, which must run + // (applied at the end, after the view panels are toggled). + + knowledgeTabToApply = "corrections"; } @@ -2268,6 +2428,12 @@ function switchView(view, updateHash = true) { }); + if (knowledgeTabToApply) { + + switchKnowledgeTab(knowledgeTabToApply); + + } + if (updateHash && window.location.hash.replace("#", "") !== view) { window.history.replaceState(null, "", `#${view}`); @@ -2346,6 +2512,12 @@ function selectCase(caseId) { suggestedQueryRunSummary = null; + // Reset per-case inputs. The decision memo is submitted with whatever case is + // selected at click time, so a memo typed for case A and left in the box was + // recorded verbatim as the rejection/correction reason for case B. + + resetCaseScopedInputs(); + switchView("workbench"); switchWorkbenchTab("evidence"); @@ -2355,6 +2527,44 @@ function selectCase(caseId) { +function resetCaseScopedInputs() { + + const memo = document.getElementById("decision-memo"); + + if (memo) { + + memo.value = ""; + + } + + const memoError = document.getElementById("memo-error"); + + if (memoError) { + + memoError.textContent = ""; + + } + + const manualQuery = document.getElementById("manual-query"); + + if (manualQuery) { + + manualQuery.value = ""; + + } + + const manualStatus = document.getElementById("manual-query-status"); + + if (manualStatus) { + + manualStatus.textContent = ""; + + } + +} + + + let isDecisionInFlight = false; async function setDecision(decision, requiresMemo = false) { @@ -2479,17 +2689,32 @@ async function runManualSearch(event) { try { + const targetCaseId = submission.id; + await apiJson("/api/search/manual", { method: "POST", - body: JSON.stringify({ submission_id: submission.id, provider, query }), + body: JSON.stringify({ submission_id: targetCaseId, provider, query }), }); - queryInput.value = ""; + // The operator may have moved to another case while the search ran. Naming + // the case the evidence actually landed on prevents "added to the current + // case" from pointing at the wrong one, and the input is only cleared when + // it still belongs to the case we searched for. - status.textContent = `${providerLabels[provider]} 쿼리 결과가 현재 케이스 증거에 추가되었습니다.`; + if (state.selectedCaseId === targetCaseId) { + + queryInput.value = ""; + + status.textContent = `${providerLabels[provider]} 쿼리 결과가 현재 케이스 증거에 추가되었습니다.`; + + } else { + + status.textContent = `${providerLabels[provider]} 쿼리 결과가 케이스 ${targetCaseId}에 추가되었습니다.`; + + } await refreshFromApi(); @@ -2585,6 +2810,663 @@ function selectedBulkSubmissionIds() { +// --- Keyboard review mode ------------------------------------------------ + + + +const KEYBOARD_HELP = [ + + "J / ↓ : 다음 케이스", + + "K / ↑ : 이전 케이스", + + "A : 승인 H : 보류 R : 반려(메모로 이동)", + + "N : 다음 미심사 케이스", + + "? : 이 도움말 열기/닫기", + + "Esc : 도움말 닫기", + +].join("\n"); + + + +function isTypingTarget(target) { + + if (!target) return false; + + const tag = String(target.tagName || "").toLowerCase(); + + return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable; + +} + + + +function moveSelection(offset) { + + const rows = filteredSubmissions(); + + if (!rows.length) return; + + const index = rows.findIndex((item) => item.id === state.selectedCaseId); + + // From "no selection", J lands on the first row and K on the last. + + const nextIndex = index === -1 + + ? (offset > 0 ? 0 : rows.length - 1) + + : Math.min(rows.length - 1, Math.max(0, index + offset)); + + selectCase(rows[nextIndex].id); + +} + + + +function moveToNextUnreviewed() { + + const rows = filteredSubmissions(); + + const index = rows.findIndex((item) => item.id === state.selectedCaseId); + + const ordered = [...rows.slice(index + 1), ...rows.slice(0, Math.max(0, index + 1))]; + + const next = ordered.find((item) => item.decisionStatus === "unreviewed"); + + if (next) { + + selectCase(next.id); + + } else { + + setImportStatus("미심사 케이스가 없습니다.", "ok"); + + } + +} + + + +function renderReviewProgress() { + + const target = document.getElementById("review-progress"); + + if (!target) return; + + const rows = filteredSubmissions(); + + const position = rows.findIndex((item) => item.id === state.selectedCaseId) + 1; + + const remaining = rows.filter((item) => item.decisionStatus === "unreviewed").length; + + target.textContent = rows.length + + ? `${position || "-"}/${rows.length} · 남은 미심사 ${remaining}건 · ? 도움말` + + : ""; + +} + + + +function handleReviewShortcut(event) { + + if (event.ctrlKey || event.metaKey || event.altKey) return; + + if (isTypingTarget(event.target)) return; + + const key = event.key; + + if (key === "Escape") { + + const help = document.getElementById("keyboard-help"); + + if (help && !help.hidden) help.hidden = true; + + return; + + } + + if (key === "?") { + + const help = document.getElementById("keyboard-help"); + + if (help) { + + help.textContent = KEYBOARD_HELP; + + help.hidden = !help.hidden; + + } + + event.preventDefault(); + + return; + + } + + // Navigation works from the queue and the workbench; decisions only make + // sense once a case is open. + + if (key === "j" || key === "J" || key === "ArrowDown") { + + moveSelection(1); + + event.preventDefault(); + + return; + + } + + if (key === "k" || key === "K" || key === "ArrowUp") { + + moveSelection(-1); + + event.preventDefault(); + + return; + + } + + if (key === "n" || key === "N") { + + moveToNextUnreviewed(); + + event.preventDefault(); + + return; + + } + + if (!state.selectedCaseId) return; + + if (key === "a" || key === "A") { + + setDecision("approved").then(() => moveToNextUnreviewed()); + + event.preventDefault(); + + return; + + } + + if (key === "h" || key === "H") { + + setDecision("held").then(() => moveToNextUnreviewed()); + + event.preventDefault(); + + return; + + } + + if (key === "r" || key === "R") { + + // Rejection needs a memo, so hand the operator the field instead of firing + // a request that would fail validation. + + switchView("workbench"); + + const memo = document.getElementById("decision-memo"); + + if (memo) memo.focus(); + + event.preventDefault(); + + } + +} + + + +// --- Duplicate / prior-case grouping ------------------------------------ + + + +function duplicateBadgeHtml(submission) { + + const group = submission.duplicateGroup; + + if (!group || !group.size) return ""; + + const priors = group.priorDecisions || []; + + const priorText = priors.length + + ? `선례: ${priors + + .map((item) => `${item.submissionId} ${decisionLabels[item.decision] || item.decision}`) + + .join(", ")}` + + : "아직 판정된 선례 없음"; + + const tone = group.conflicting ? " conflicting" : ""; + + const conflictNote = group.conflicting ? " · 판정 불일치" : ""; + + return ``; + +} + + + +function selectDuplicateGroup(submissionId) { + + // Tick the whole group so the operator can hand it to batch re-analysis or a + // batch decision in one move. + + const submission = submissions.find((item) => item.id === submissionId); + + const group = submission && submission.duplicateGroup; + + if (!group) return; + + const ids = new Set([submissionId, ...(group.peerIds || [])]); + + document.querySelectorAll("input[data-bulk-id]").forEach((input) => { + + input.checked = ids.has(input.dataset.bulkId); + + }); + + renderBulkDecisionCount(); + + const status = document.getElementById("bulk-decision-status"); + + if (status) { + + const priors = group.priorDecisions || []; + + status.textContent = priors.length + + ? `동일 이미지 ${ids.size}건 선택 · 선례 ${priors + + .map((item) => `${item.submissionId} ${decisionLabels[item.decision] || item.decision}`) + + .join(", ")}` + + : `동일 이미지 ${ids.size}건을 선택했습니다.`; + + } + +} + + + +// --- Batch decisions --------------------------------------------------- + + + +function checkedBulkSubmissionIds() { + + // Unlike selectedBulkSubmissionIds(), this never falls back to the current + // case: a batch decision must act on an explicit selection only. + + return Array.from(document.querySelectorAll("input[data-bulk-id]:checked")).map( + + (input) => input.dataset.bulkId, + + ); + +} + + + +function renderBulkDecisionCount() { + + const target = document.getElementById("bulk-decision-count"); + + if (!target) return; + + const ids = checkedBulkSubmissionIds(); + + const highRisk = ids.filter((id) => { + + const submission = submissions.find((item) => item.id === id); + + return submission && submission.riskBand === "high"; + + }).length; + + target.textContent = highRisk + + ? `선택 ${ids.length}건 (고위험 ${highRisk}건)` + + : `선택 ${ids.length}건`; + +} + + + +let isBulkDecisionInFlight = false; + + + +async function applyBulkDecision(decision) { + + if (isBulkDecisionInFlight) return; + + const ids = checkedBulkSubmissionIds(); + + const status = document.getElementById("bulk-decision-status"); + + const setStatus = (message) => { + + if (status) status.textContent = message; + + }; + + if (!ids.length) { + + setStatus("판정할 케이스를 먼저 선택하세요."); + + return; + + } + + const memo = document.getElementById("decision-memo")?.value.trim() || ""; + + if (decision === "rejected" && !memo) { + + setStatus("일괄 반려에는 결정 메모가 필요합니다. 메모를 입력한 뒤 다시 시도하세요."); + + return; + + } + + const highRisk = ids.filter((id) => { + + const submission = submissions.find((item) => item.id === id); + + return submission && submission.riskBand === "high"; + + }); + + // Guardrail: approving a high-risk case in bulk is the one irreversible-feeling + // action here, so name the count instead of asking a generic "are you sure". + + if (highRisk.length) { + + const label = decisionLabels[decision] || decision; + + if (!window.confirm(`선택 ${ids.length}건 중 고위험 ${highRisk.length}건이 포함되어 있습니다. 그대로 ${label} 처리할까요?`)) { + + setStatus("일괄 판정을 취소했습니다."); + + return; + + } + + } + + isBulkDecisionInFlight = true; + + setStatus(`${ids.length}건 판정 중…`); + + try { + + const payload = await apiJson("/api/submissions/decision-batch", { + + method: "POST", + + body: JSON.stringify({ submission_ids: ids, decision, memo }), + + }); + + const result = payload.batchDecision || { applied: [], failed: [] }; + + applyBootstrap(payload); + + renderAll(); + + const failures = result.failed || []; + + setStatus( + + failures.length + + ? `${(result.applied || []).length}건 적용 · ${failures.length}건 실패: ${failures + + .map((item) => `${item.submissionId}(${item.error})`) + + .join(", ")}` + + : `${(result.applied || []).length}건 판정을 적용했습니다.`, + + ); + + } catch (errorValue) { + + setStatus(`일괄 판정 실패: ${errorValue.message}`); + + } finally { + + isBulkDecisionInFlight = false; + + } + +} + + + +// --- Decision memo presets --------------------------------------------- + + + +function renderMemoPresets() { + + const target = document.getElementById("memo-presets"); + + if (!target) return; + + const chips = decisionMemoPresets + + .map( + + (preset) => + + ``, + + ) + + .join(""); + + target.innerHTML = `${chips}`; + +} + + + +function applyMemoPreset(presetId) { + + const preset = decisionMemoPresets.find((item) => item.id === presetId); + + const memo = document.getElementById("decision-memo"); + + if (!preset || !memo) return; + + memo.value = memo.value.trim() ? `${memo.value.trim()} ${preset.body}` : preset.body; + + memo.focus(); + +} + + + +async function saveCurrentMemoAsPreset() { + + const memo = document.getElementById("decision-memo"); + + const error = document.getElementById("memo-error"); + + const body = memo?.value.trim() || ""; + + if (!body) { + + if (error) error.textContent = "저장할 메모 내용이 없습니다."; + + return; + + } + + const label = window.prompt("프리셋 이름 (예: 연예인 초상)", body.slice(0, 20)); + + if (!label) return; + + try { + + const presets = await apiJson("/api/decision-memo-presets", { + + method: "POST", + + body: JSON.stringify({ label, body }), + + }); + + replaceCollection(decisionMemoPresets, presets); + + renderMemoPresets(); + + if (error) error.textContent = ""; + + } catch (errorValue) { + + if (error) error.textContent = `프리셋 저장 실패: ${errorValue.message}`; + + } + +} + + + +// --- Decision export ---------------------------------------------------- + + + +async function exportDecisions() { + + const status = document.getElementById("bulk-decision-status"); + + const audience = window.confirm( + + "내부 보관용(증거·점수 포함)으로 내보내려면 확인, 신청 채널 전달용(판정·메모만)으로 내보내려면 취소를 누르세요.", + + ) + + ? "internal" + + : "applicant"; + + try { + + const payload = await apiJson( + + `/api/decisions/export?audience=${encodeURIComponent(audience)}`, + + ); + + if (!payload.rows.length) { + + if (status) status.textContent = "내보낼 판정 완료 케이스가 없습니다."; + + return; + + } + + downloadTextFile( + + `copyrighter-decisions-${audience}.csv`, + + payload.csv, + + "text/csv;charset=utf-8", + + ); + + if (status) status.textContent = `${payload.rows.length}건을 ${audience === "internal" ? "내부 보관용" : "신청 채널 전달용"} CSV로 내보냈습니다.`; + + } catch (errorValue) { + + if (status) status.textContent = `내보내기 실패: ${errorValue.message}`; + + } + +} + + + +function downloadTextFile(filename, text, mimeType) { + + // BOM so Excel opens the Korean CSV as UTF-8 instead of the system codepage. + + const blob = new Blob([`${text}`], { type: mimeType }); + + const url = URL.createObjectURL(blob); + + const link = document.createElement("a"); + + link.href = url; + + link.download = filename; + + document.body.appendChild(link); + + link.click(); + + document.body.removeChild(link); + + URL.revokeObjectURL(url); + +} + + + +// --- Backup ------------------------------------------------------------- + + + +async function createBackupSnapshot() { + + const status = document.getElementById("bulk-decision-status"); + + const button = document.getElementById("create-backup"); + + if (button) button.disabled = true; + + if (status) status.textContent = "백업 스냅샷을 만드는 중…"; + + try { + + const result = await apiJson("/api/admin/backup", { method: "POST" }); + + const megabytes = (Number(result.sizeBytes || 0) / (1024 * 1024)).toFixed(1); + + if (status) { + + status.textContent = `백업 완료: ${result.file} (${megabytes}MB · 미디어 ${result.mediaFiles}개)`; + + } + + await refreshFromApi(); + + } catch (errorValue) { + + if (status) status.textContent = `백업 실패: ${errorValue.message}`; + + } finally { + + if (button) button.disabled = false; + + } + +} + + + async function rerunSelectedEnrichment() { const ids = selectedBulkSubmissionIds(); @@ -3278,9 +4160,19 @@ async function promoteCollectionCandidate(candidateId) { function addKnowledgeEntryFromCase() { + // The control is labelled "주의 후보 보기" (view watchlist candidates), so show + // the registered list filtered to watchlist — it previously opened the blank + // manual-registration form, which is a different action entirely. + switchView("knowledge"); - switchKnowledgeTab("manual"); + switchKnowledgeTab("registered"); + + knowledgeFilters.status = "watchlist"; + + const statusFilter = document.getElementById("knowledge-status-filter"); + + if (statusFilter) statusFilter.value = "watchlist"; renderAll(); @@ -3605,6 +4497,16 @@ function bindEvents() { document.getElementById("queue-body").addEventListener("click", (event) => { + const duplicateButton = event.target.closest("[data-duplicate-group]"); + + if (duplicateButton) { + + selectDuplicateGroup(duplicateButton.dataset.duplicateGroup); + + return; + + } + const selectButton = event.target.closest("[data-select-case]"); if (selectButton) { @@ -3627,6 +4529,20 @@ function bindEvents() { + // Keep the batch-decision counter in step with the row checkboxes. + + document.getElementById("queue-body").addEventListener("change", (event) => { + + if (event.target.matches("input[data-bulk-id]")) { + + renderBulkDecisionCount(); + + } + + }); + + + document.getElementById("coverage-tabs").addEventListener("click", (event) => { const tab = event.target.closest("[data-coverage-filter]"); @@ -3765,6 +4681,34 @@ function bindEvents() { document.getElementById("bulk-rerun").addEventListener("click", rerunSelectedEnrichment); + document.querySelectorAll("[data-bulk-decision]").forEach((button) => { + + button.addEventListener("click", () => applyBulkDecision(button.dataset.bulkDecision)); + + }); + + document.getElementById("export-decisions").addEventListener("click", exportDecisions); + + document.getElementById("create-backup").addEventListener("click", createBackupSnapshot); + + document.getElementById("memo-presets").addEventListener("click", (event) => { + + if (event.target.closest("#memo-preset-save")) { + + void saveCurrentMemoAsPreset(); + + return; + + } + + const chip = event.target.closest("[data-preset-id]"); + + if (chip) applyMemoPreset(chip.dataset.presetId); + + }); + + document.addEventListener("keydown", handleReviewShortcut); + document.getElementById("emergency-disable").addEventListener("click", emergencyDisableExternalProviders); diff --git a/web/operator-gui/index.html b/web/operator-gui/index.html index a32e3ac..a9e92bd 100644 --- a/web/operator-gui/index.html +++ b/web/operator-gui/index.html @@ -13,6 +13,20 @@ +
+
+
@@ -204,6 +229,7 @@ 결정 메모 +

diff --git a/web/operator-gui/styles.css b/web/operator-gui/styles.css index 2858953..f44342b 100644 --- a/web/operator-gui/styles.css +++ b/web/operator-gui/styles.css @@ -2817,3 +2817,191 @@ tbody tr.selected-row, .evidence-row-new { box-shadow: inset 0 0 0 2px var(--teal); } + +/* --- Auth gate ------------------------------------------------------- */ +/* Shown only when the server runs with COPYRIGHTER_AUTH_TOKEN and a data + route answered 401. Covers the shell so no half-loaded queue is visible + behind it. */ +.auth-gate { + position: fixed; + inset: 0; + z-index: 100; + display: grid; + place-items: center; + padding: 24px; + background: rgba(16, 23, 29, 0.55); + backdrop-filter: blur(2px); +} + +.auth-gate[hidden] { + display: none; +} + +.auth-gate-card { + width: min(420px, 100%); + display: flex; + flex-direction: column; + gap: 10px; + padding: 26px 26px 22px; + background: var(--card-raised); + border: 1px solid var(--hair); + border-radius: var(--r-lg); + box-shadow: var(--shadow-lg); +} + +.auth-gate-card h1 { + margin: 0; + font-size: 1.05rem; + letter-spacing: -0.01em; + color: var(--ink); +} + +.auth-gate-hint { + margin: 0; + font-size: 0.82rem; + line-height: 1.55; + color: var(--ink-soft); +} + +.auth-gate-hint code { + font-family: var(--font-mono); + font-size: 0.78rem; + padding: 1px 5px; + border-radius: var(--r-sm); + background: var(--card-sunk); + border: 1px solid var(--hair-soft); +} + +.auth-gate-label { + margin-top: 4px; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--ink-soft); +} + +.auth-gate-input { + width: 100%; + padding: 9px 11px; + font-family: var(--font-mono); + font-size: 0.86rem; + color: var(--ink); + background: var(--card); + border: 1px solid var(--hair-strong); + border-radius: var(--radius); +} + +.auth-gate-input:focus { + outline: 2px solid var(--teal-line); + outline-offset: 1px; + border-color: var(--teal); +} + +.auth-gate-error { + margin: 0; + font-size: 0.78rem; + color: var(--red); +} + +.auth-gate-error[hidden] { + display: none; +} + +.auth-gate-card .primary-action { + margin-top: 6px; + justify-content: center; +} + +/* --- Batch decisions / review throughput ------------------------------ */ +.queue-actions-secondary { + margin-top: 8px; + padding-top: 8px; + border-top: 1px dashed var(--hair); + align-items: center; +} + +.bulk-decision-label { + font-size: 0.78rem; + font-weight: 600; + color: var(--ink-soft); + font-variant-numeric: tabular-nums; +} + +.review-progress { + margin-left: auto; + font-size: 0.76rem; + color: var(--ink-faint); + font-variant-numeric: tabular-nums; +} + +.keyboard-help { + margin: 8px 0 0; + padding: 12px 14px; + font-family: var(--font-mono); + font-size: 0.78rem; + line-height: 1.7; + color: var(--ink-soft); + background: var(--card-sunk); + border: 1px solid var(--hair); + border-radius: var(--radius); + white-space: pre; + overflow-x: auto; +} + +.keyboard-help[hidden] { + display: none; +} + +/* --- Decision memo presets -------------------------------------------- */ +.memo-presets { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 6px; +} + +.memo-preset-chip { + padding: 3px 9px; + font-size: 0.74rem; + color: var(--ink-soft); + background: var(--card-sunk); + border: 1px solid var(--hair); + border-radius: var(--r-pill); + cursor: pointer; +} + +.memo-preset-chip:hover { + color: var(--teal-deep); + border-color: var(--teal-line); + background: var(--teal-tint-soft); +} + +.memo-preset-add { + border-style: dashed; + color: var(--ink-faint); +} + +/* --- Duplicate group badge -------------------------------------------- */ +.duplicate-badge { + display: inline-block; + margin-top: 3px; + padding: 2px 8px; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.01em; + color: var(--blue); + background: var(--blue-tint); + border: 1px solid var(--blue-line); + border-radius: var(--r-pill); + cursor: pointer; +} + +.duplicate-badge:hover { + filter: brightness(0.97); +} + +.duplicate-badge.conflicting { + color: var(--red); + background: var(--red-tint); + border-color: var(--red-line); +}