feat: operator throughput features (keyboard review, batch, dupes, export, backup)

- Keyboard review mode: J/K to move, A/H to decide and jump to the next
  unreviewed case, R to focus the memo, N for next unreviewed, ? for help, with
  a live position/backlog indicator; disabled while typing.
- Batch decisions: POST /api/submissions/decision-batch applies one decision to
  many selected cases through per-case transactions/audit/watchlist, reports
  per-case success and failure, caps at 200, requires a memo for reject and
  confirms when high-risk cases are included. Lifts the v1 "no bulk
  approve/reject" spec restriction under those guardrails (spec revised, static
  layout test updated to assert the guardrails instead of the absence).
- Decision-memo presets: GET/POST /api/decision-memo-presets (upsert by label),
  one-click chips in the decision pane, "save current memo" action.
- Submission duplicate / prior-case index: seed backfills fingerprints onto the
  submission record (idempotent), exact + pHash>=0.97 clustering, queue badge
  and group select, prior-decision chips and a conflicting-decision flag.
- Decision export: GET /api/decisions/export with separate internal and
  applicant field whitelists (applicant view never carries score/evidence),
  CSV with a BOM.
- Online backup/restore: POST /api/admin/backup snapshots the WAL database via
  the sqlite backup API plus the media dirs into a timestamped zip with
  retention rotation; `--restore` is an offline CLI that keeps the pre-restore
  database. GET /api/admin/backups lists snapshots.

Also folds in the co-located HTTP hardening (non-object/non-UTF8 JSON body ->
400, non-ASCII Authorization/cookie compared as bytes -> 401, media OSError ->
404) and the line-buffered startup logging that share these files.

Verified against a live server (auth gate, seeding, duplicate index, batch
decision guardrails, presets, export, backup + restore round-trip). pytest
tests/rights_filter tests/operator_gui: 457 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E6GD9gxUtfUiVB3JHejGpU
This commit is contained in:
changukyu 2026-07-24 14:18:11 +09:00
parent db9ee9a7ca
commit a2d0ef7a36
12 changed files with 2298 additions and 28 deletions

View file

@ -2,12 +2,14 @@ from __future__ import annotations
import argparse import argparse
import os import os
import sys
from pathlib import Path from pathlib import Path
from rights_filter.integrations.env_clients import build_provider_runtime from rights_filter.integrations.env_clients import build_provider_runtime
from rights_filter.server.env_file import load_env_file from rights_filter.server.env_file import load_env_file
from rights_filter.server.http_app import build_server from rights_filter.server.http_app import build_server
from rights_filter.server.image_store import LocalSubmissionImageStore 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 from rights_filter.server.sqlite_store import CopyrighterStore
@ -24,6 +26,16 @@ def _provider_readiness(runtime) -> str:
def main() -> None: 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 = argparse.ArgumentParser(description="Run the local Copyrighter API server.")
parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=9500) 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("--images", default="data/submissions")
parser.add_argument("--static", default="web/operator-gui") parser.add_argument("--static", default="web/operator-gui")
parser.add_argument("--env", default=".env") 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() 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) env_path = Path(args.env)
load_env_file(env_path, os.environ) load_env_file(env_path, os.environ)
# Log the resolved env path (and whether it was found) so an operator running # Log the resolved env path (and whether it was found) so an operator running

View file

@ -11,7 +11,7 @@ from http import HTTPStatus
from http.cookies import SimpleCookie from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path 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.image_store import LocalSubmissionImageStore, SUPPORTED_IMAGE_SUFFIXES
from rights_filter.server.sqlite_store import CopyrighterStore from rights_filter.server.sqlite_store import CopyrighterStore
@ -114,6 +114,19 @@ def build_server(
self._json(store.providers()) self._json(store.providers())
elif path == "/api/audit-events": elif path == "/api/audit-events":
self._json(store.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/"): elif path.startswith("/media/"):
self._file(active_store().media_path(unquote(path.removeprefix("/media/"))), untrusted=True) self._file(active_store().media_path(unquote(path.removeprefix("/media/"))), untrusted=True)
elif path.startswith("/knowledge-media/"): elif path.startswith("/knowledge-media/"):
@ -229,6 +242,29 @@ def build_server(
elif path.startswith("/api/knowledge/") and path.endswith("/reactivate"): elif path.startswith("/api/knowledge/") and path.endswith("/reactivate"):
entry_id = unquote(path.split("/")[3]) entry_id = unquote(path.split("/")[3])
self._json(store.reactivate_knowledge_entry(entry_id, str(body.get("reason", "")))) 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": elif path == "/api/providers/emergency-disable":
self._json(store.emergency_disable_external_providers()) self._json(store.emergency_disable_external_providers())
else: else:
@ -288,7 +324,13 @@ def build_server(
if not any(path.startswith(prefix) for prefix in _PROTECTED_PREFIXES): if not any(path.startswith(prefix) for prefix in _PROTECTED_PREFIXES):
return True return True
header = self.headers.get("Authorization", "") 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 # First header-authenticated request mints an HttpOnly session
# cookie so the browser can load <img>/media (which cannot send an # cookie so the browser can load <img>/media (which cannot send an
# Authorization header) without ever putting the token in a URL. # Authorization header) without ever putting the token in a URL.
@ -299,7 +341,10 @@ def build_server(
jar = SimpleCookie() jar = SimpleCookie()
jar.load(cookie_header) jar.load(cookie_header)
morsel = jar.get(_SESSION_COOKIE) 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 # Cookie-authenticated state changes are CSRF-forgeable, so
# require a same-origin Origin/Referer for them. # require a same-origin Origin/Referer for them.
if self.command in _STATE_CHANGING_METHODS and not self._origin_ok(): if self.command in _STATE_CHANGING_METHODS and not self._origin_ok():
@ -338,9 +383,18 @@ def build_server(
if not length: if not length:
return {} return {}
try: 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: except json.JSONDecodeError as exc:
raise ValueError("invalid JSON body") from 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: def _maybe_set_session_cookie(self) -> None:
if getattr(self, "_issue_session_cookie", False): if getattr(self, "_issue_session_cookie", False):
@ -374,7 +428,14 @@ def build_server(
if not path.exists() or path.is_dir(): if not path.exists() or path.is_dir():
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND) self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
return 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" 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"}: if content_type.startswith("text/") or content_type in {"application/javascript", "application/json"}:
content_type = f"{content_type}; charset=utf-8" content_type = f"{content_type}; charset=utf-8"

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import os import os
import shutil import shutil
import sqlite3 import sqlite3
@ -38,10 +39,13 @@ from rights_filter.server.store_remote_fetch import (
_fetch_stylesheet_url_bytes, _fetch_stylesheet_url_bytes,
_fetch_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_enrichment import StoreEnrichmentMixin
from rights_filter.server.store_operations import StoreOperationsMixin from rights_filter.server.store_operations import StoreOperationsMixin
from rights_filter.server.store_persistence import StorePersistenceMixin from rights_filter.server.store_persistence import StorePersistenceMixin
from rights_filter.server.store_queue import StoreQueueMixin 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_search_candidates import StoreSearchCandidatesMixin
from rights_filter.server.store_schema import ( from rights_filter.server.store_schema import (
_ensure_constrained_schema, _ensure_constrained_schema,
@ -61,6 +65,13 @@ from rights_filter.server.store_serialization import (
_submission_search_hint_evidence, _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( class CopyrighterStore(
StorePersistenceMixin, StorePersistenceMixin,
@ -68,6 +79,9 @@ class CopyrighterStore(
StoreSearchCandidatesMixin, StoreSearchCandidatesMixin,
StoreEnrichmentMixin, StoreEnrichmentMixin,
StoreOperationsMixin, StoreOperationsMixin,
StoreDuplicatesMixin,
StoreWorkflowMixin,
StoreBackupMixin,
): ):
def __init__( def __init__(
self, self,
@ -207,6 +221,10 @@ class CopyrighterStore(
object_ref text not null default '', object_ref text not null default '',
payload text not null check (json_valid(payload)) 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) _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: def seed_from_image_store(self, image_store: LocalSubmissionImageStore) -> int:
# Serialize seeding so two concurrent reload/import/upload requests can't # Serialize seeding so two concurrent reload/import/upload requests can't
# both classify the same record as new and double-run analysis, external # both classify the same record as new and double-run analysis, external
@ -253,11 +313,25 @@ class CopyrighterStore(
existing_submission_ids = { existing_submission_ids = {
submission["id"] for submission in self._all("submissions", queue_id=queue_id) submission["id"] for submission in self._all("submissions", queue_id=queue_id)
} }
records = [ # The "is this new?" test above is queue-scoped, but the submissions PK is
record # global and _put() upserts on it. A record whose id (the file stem) is
for record in image_store.submission_records() # already owned by a DIFFERENT queue would therefore be classified as new
if record["id"] not in existing_submission_ids # 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: if not records:
return 0 return 0
@ -338,6 +412,10 @@ class CopyrighterStore(
) )
self._sync_submission_provider_state(queue_id=queue_id) self._sync_submission_provider_state(queue_id=queue_id)
self._normalize_saved_google_weak_labels_and_rescore(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: with self._connect() as conn:
conn.execute( conn.execute(
""" """
@ -393,8 +471,14 @@ class CopyrighterStore(
queue_id = str(queue["id"]) if queue else None queue_id = str(queue["id"]) if queue else None
submissions = self._all("submissions", queue_id=queue_id) submissions = self._all("submissions", queue_id=queue_id)
evidence = self._evidence_by_submission(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: for submission in submissions:
submission["evidence"] = evidence.get(submission["id"], []) 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 { return {
"submissions": submissions, "submissions": submissions,
"submissionQueue": queue, "submissionQueue": queue,
@ -405,6 +489,7 @@ class CopyrighterStore(
"auditEvents": self.audit_events(), "auditEvents": self.audit_events(),
"coverageThresholds": dict(self.coverage_thresholds), "coverageThresholds": dict(self.coverage_thresholds),
"searchCoverage": self._search_coverage(submissions, evidence), "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]: 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 # Attribute via _evidence_matches_provider (domain-aware) so Google
# Custom Search (source "google", domain "google_custom_search") is # Custom Search (source "google", domain "google_custom_search") is
# counted under google_search, not the Vision "google" bucket. # 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"): 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 has_search_evidence = True
provider_stats[search_provider]["evidenceSubmissions"] += 1 provider_stats[search_provider]["evidenceSubmissions"] += 1
if has_search_evidence: if has_search_evidence:

View file

@ -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

View file

@ -23,6 +23,7 @@ STORE_TABLES = {
"corrections", "corrections",
"audit_events", "audit_events",
"submission_queues", "submission_queues",
"decision_memo_presets",
} }
RISK_BANDS = ("low", "medium", "high", "failed", "pending") RISK_BANDS = ("low", "medium", "high", "failed", "pending")
DECISION_STATUSES = ("unreviewed", "held", "rejected", "approved", "corrected") DECISION_STATUSES = ("unreviewed", "held", "rejected", "approved", "corrected")

View file

@ -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"})

View file

@ -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 "<decision> · <memo>".
_, 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()

View file

@ -128,8 +128,14 @@ def test_workbench_shell_exposes_all_internal_operator_views():
assert "추천 쿼리로 외부 검색 결과를 보강합니다." in html assert "추천 쿼리로 외부 검색 결과를 보강합니다." in html
assert "기준 DB에 반영합니다." in html assert "기준 DB에 반영합니다." in html
assert ".operator-workflow" in styles assert ".operator-workflow" in styles
assert "bulk-approve" not in html.lower() # 2026-07-24: the v1 "no bulk approve/reject" restriction was lifted (see
assert "bulk-reject" not in html.lower() # 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 'data-view="evidence"' not in html
assert 'id="evidence-view"' not in html assert 'id="evidence-view"' not in html

View file

@ -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")

File diff suppressed because it is too large Load diff

View file

@ -13,6 +13,20 @@
<script src="app.js" defer></script> <script src="app.js" defer></script>
</head> </head>
<body data-internal-only="true"> <body data-internal-only="true">
<div class="auth-gate" id="auth-gate" hidden role="dialog" aria-modal="true" aria-labelledby="auth-gate-title">
<form class="auth-gate-card" id="auth-gate-form" autocomplete="off">
<h1 id="auth-gate-title">접근 토큰 입력</h1>
<p class="auth-gate-hint">
이 서버는 인증이 설정되어 있습니다. 운영 담당자에게 받은
<code>COPYRIGHTER_AUTH_TOKEN</code> 값을 입력하세요.
</p>
<label class="auth-gate-label" for="auth-gate-input">접근 토큰</label>
<input class="auth-gate-input" id="auth-gate-input" type="password" required
autocomplete="off" spellcheck="false" aria-describedby="auth-gate-error">
<p class="auth-gate-error" id="auth-gate-error" role="alert" hidden></p>
<button class="primary-action" id="auth-gate-submit" type="submit">인증</button>
</form>
</div>
<div class="app-shell"> <div class="app-shell">
<nav class="nav-rail" aria-label="내부 운영 콘솔"> <nav class="nav-rail" aria-label="내부 운영 콘솔">
<div class="brand-block"> <div class="brand-block">
@ -83,7 +97,18 @@
<button class="primary-action" type="button" id="reload-submissions">새 제출 불러오기</button> <button class="primary-action" type="button" id="reload-submissions">새 제출 불러오기</button>
<button class="secondary-action" type="button" id="bulk-rerun">선택 재분석</button> <button class="secondary-action" type="button" id="bulk-rerun">선택 재분석</button>
</div> </div>
<div class="queue-actions queue-actions-secondary">
<span class="bulk-decision-label" id="bulk-decision-count">선택 0건</span>
<button class="approve-action" type="button" id="bulk-approve" data-bulk-decision="approved">선택 승인</button>
<button class="hold-action" type="button" id="bulk-hold" data-bulk-decision="held">선택 보류</button>
<button class="reject-action" type="button" id="bulk-reject" data-bulk-decision="rejected">선택 반려</button>
<button class="secondary-action" type="button" id="export-decisions">판정 내보내기</button>
<button class="secondary-action" type="button" id="create-backup">백업 만들기</button>
<span class="review-progress" id="review-progress" aria-live="polite"></span>
</div>
</div> </div>
<div class="import-status" id="bulk-decision-status" aria-live="polite"></div>
<pre class="keyboard-help" id="keyboard-help" hidden></pre>
<div class="import-status" id="submission-import-status" aria-live="polite"></div> <div class="import-status" id="submission-import-status" aria-live="polite"></div>
<section class="operator-workflow" aria-label="운영 흐름"> <section class="operator-workflow" aria-label="운영 흐름">
@ -204,6 +229,7 @@
<span>결정 메모</span> <span>결정 메모</span>
<textarea id="decision-memo" rows="3" placeholder="반려 또는 보정 시 메모 필수"></textarea> <textarea id="decision-memo" rows="3" placeholder="반려 또는 보정 시 메모 필수"></textarea>
</label> </label>
<div class="memo-presets" id="memo-presets" aria-label="자주 쓰는 사유"></div>
<p class="form-error" id="memo-error" aria-live="assertive"></p> <p class="form-error" id="memo-error" aria-live="assertive"></p>
<div class="decision-actions"> <div class="decision-actions">
<button class="approve-action" type="button" data-decision="approved">승인</button> <button class="approve-action" type="button" data-decision="approved">승인</button>

View file

@ -2817,3 +2817,191 @@ tbody tr.selected-row,
.evidence-row-new { .evidence-row-new {
box-shadow: inset 0 0 0 2px var(--teal); 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);
}