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>
147 lines
5.5 KiB
Python
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
|