Files
photoanalyzer/tests/integration/test_safety_analysis.py
domverse 457dd5bd90 US02-06: Deliver Workflow, Safety, Library, Analysis, and Stats Views
Ports the safety review and the Photo Analyzer Library/Analyze/Stats
experiences onto the shared API + service layer, and adds the Workflow
home, enforcing the pipeline gates and the one-mutating-job policy.

Backend
- migration 0005 + models: safety_reviews (append-only, latest row is the
  current decision) and analysis_results (donor photos schema re-keyed to
  asset_id).
- SafetyService: persist scores/decisions, review queue with filters, and
  the EXIF safety checkpoint (mutually-exclusive sfw/nsfw keyword written,
  read back, current_sha256 refreshed) that upload eligibility depends on.
- AnalysisService: the privacy gate — the vision provider is called ONLY for
  canonical, confirmed-SFW assets; nsfw/undecided are recorded skipped without
  a request. Provider is an injected adapter (real OpenAI-compatible Gemini
  call extracted from photo_analyzer.analyze_image; a fake in tests).
- LibraryService: Library search + Stats read model ported from webapp/query.py
  (LIKE search in place of FTS5; facets, top tags, years, albums, people).
- WorkflowService + GET /api/v1/workflow: per-stage readiness derived from the
  source tables — counts, blockers, last-run, action, and an active_job that
  drives read-only-during-jobs. Safety scoring and analysis run as durable jobs
  under the library_write lock via new domain handlers, so a second mutating
  job is refused.
- routes: workflow, safety (queue/counts/decisions/jobs), analysis
  (counts/results/jobs), library (assets/facets/stats).

Frontend
- five views (frontend/js/views.js) on the US02-05 shell: Workflow stepper
  (status text+icon, not colour alone; actions disabled with a reason while a
  job runs), Safety review (filter tabs, decide, persists across reload),
  Library (search + cards), Analyze (counts + live job log via the SSE
  adapter), Stats. Shared DOM helpers extracted to dom.js; Workflow is the home
  route.

Tests
- integration: provider-call privacy (nsfw never reaches the provider),
  sfw→nsfw flip drops analysis eligibility, decision persistence, one-mutating-
  job rejection, workflow counts, and the exiftool safety-keyword write/verify.
- e2e: Workflow cards, actions disabled+explained during a job, safety
  decide-persists-across-reload, Library search, Stats, Analyze counts.
- traceability map updated for US02-05 and US02-06.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 16:52:01 +02:00

147 lines
5.5 KiB
Python

"""Safety decisions, the NSFW→analysis privacy gate, and one-mutating-job policy
(US02-06).
The privacy invariant is the headline: the vision provider is called ONLY for
confirmed-SFW assets. A call-recording fake provider proves nsfw/undecided assets
never produce a request, with no network or API key.
"""
import shutil
import uuid
from datetime import datetime, timezone
import pytest
from fastapi.testclient import TestClient
from PIL import Image
from photo_pipeline.api.app import create_app
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
from photo_pipeline.models import Asset
from photo_pipeline.services.analysis import AnalysisService
from photo_pipeline.services.safety import SafetyService
def _factory(tmp_path):
(tmp_path / "data").mkdir()
config = Config.from_env({"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data")})
run_migrations(config.database_url)
return config, create_session_factory(create_db_engine(config.database_url))
def _seed_assets(sf, paths):
now = datetime.now(timezone.utc)
ids = []
with sf() as session:
for path in paths:
asset = Asset(
id=str(uuid.uuid4()),
original_path=str(path),
current_path=str(path),
discovered_at=now,
hash_version=1,
)
session.add(asset)
ids.append(asset.id)
session.commit()
return ids
class RecordingProvider:
def __init__(self):
self.calls = []
def analyze(self, path, *, album_hint):
self.calls.append(path)
return {"description": "a photo", "tags": ["alpha", "beta"], "people_count": 1}
def test_provider_called_only_for_confirmed_sfw(tmp_path):
_, sf = _factory(tmp_path)
sfw, nsfw, undecided = _seed_assets(sf, ["/lib/a.jpg", "/lib/b.jpg", "/lib/c.jpg"])
safety = SafetyService(sf)
safety.decide(sfw, "sfw", write_exif=False)
safety.decide(nsfw, "nsfw", write_exif=False)
# `undecided` gets no decision at all.
provider = RecordingProvider()
result = AnalysisService(sf, provider=provider).run([sfw, nsfw, undecided])
assert provider.calls == ["/lib/a.jpg"], "provider must see only the SFW asset"
assert result == {"analyzed": 1, "skipped": 2, "errors": 0}
def test_flipping_sfw_to_nsfw_removes_analysis_eligibility(tmp_path):
_, sf = _factory(tmp_path)
(asset,) = _seed_assets(sf, ["/lib/a.jpg"])
safety = SafetyService(sf)
analysis = AnalysisService(sf, provider=RecordingProvider())
safety.decide(asset, "sfw", write_exif=False)
assert analysis.eligible_asset_ids() == [asset]
safety.decide(asset, "nsfw", write_exif=False)
assert analysis.eligible_asset_ids() == [] # latest decision wins
def test_decision_persists_and_shows_in_queue(tmp_path):
config, sf = _factory(tmp_path)
(asset,) = _seed_assets(sf, ["/lib/a.jpg"])
app = create_app(config)
with TestClient(app) as client:
ok = client.post("/api/v1/safety/decisions", json={"asset_id": asset, "decision": "nsfw"})
assert ok.status_code == 200 and ok.json()["decision"] == "nsfw"
nsfw_queue = client.get("/api/v1/safety/queue", params={"state": "nsfw"}).json()
assert [row["asset_id"] for row in nsfw_queue["items"]] == [asset]
assert client.get("/api/v1/safety/counts").json()["nsfw"] == 1
unknown = TestClient(app)
with unknown as client:
bad = client.post("/api/v1/safety/decisions", json={"asset_id": "nope", "decision": "sfw"})
assert bad.status_code == 404
def test_one_mutating_job_at_a_time(tmp_path):
config, sf = _factory(tmp_path)
ids = _seed_assets(sf, ["/lib/a.jpg", "/lib/b.jpg"])
SafetyService(sf).decide(ids[0], "sfw", write_exif=False) # make analysis eligible
app = create_app(config)
with TestClient(app) as client:
first = client.post("/api/v1/safety/jobs")
assert first.status_code == 200
# Both scoring and analysis take the library_write lock — the second is refused.
second = client.post("/api/v1/analysis/jobs")
assert second.status_code == 409
workflow = client.get("/api/v1/workflow").json()
assert workflow["active_job"] is not None
assert workflow["active_job"]["lock"] == "library_write"
def test_workflow_counts_reflect_decisions(tmp_path):
config, sf = _factory(tmp_path)
ids = _seed_assets(sf, ["/lib/a.jpg", "/lib/b.jpg", "/lib/c.jpg"])
SafetyService(sf).decide(ids[0], "sfw", write_exif=False)
app = create_app(config)
with TestClient(app) as client:
stages = {s["key"]: s for s in client.get("/api/v1/workflow").json()["stages"]}
assert stages["safety"]["counts"] == {"sfw": 1, "nsfw": 0, "deferred": 0, "undecided": 2, "scored": 0}
assert stages["safety"]["status"] == "attention"
assert stages["analysis"]["counts"]["eligible"] == 1
@pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool not installed")
def test_safety_decision_writes_and_verifies_exif(tmp_path):
_, sf = _factory(tmp_path)
image = tmp_path / "photo.jpg"
Image.new("RGB", (32, 32), (120, 60, 30)).save(image)
(asset,) = _seed_assets(sf, [image])
result = SafetyService(sf).decide(asset, "nsfw")
assert result["exif_verified"] is True
from photo_pipeline.integrations import exiftool
keywords = exiftool.read_keyword_sets([str(image)]).get(str(image), set())
assert "nsfw" in keywords and "sfw" not in keywords