Files
photoanalyzer/photo_pipeline/services/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

292 lines
11 KiB
Python

"""AnalysisService — content analysis scoped to confirmed-SFW assets.
The privacy invariant is enforced here and nowhere else: the vision provider is
called **only** for canonical assets whose latest safety decision is ``sfw``.
Confirmed ``nsfw`` and undecided assets never reach the provider — ``run`` records
them as ``skipped_nsfw``/leaves them pending without constructing a request. The
provider is an injected adapter so tests assert this with a call-recording fake and
no network/key.
Extracted from photo_analyzer.analyze_image (the OpenAI-compatible Gemini call,
result fields, and album-hint prompt) and its ``photos`` schema, re-keyed to
``asset_id`` (donor_ledger.yaml: pa-analyze, pa-schema). Retry/rate-limit/throttle
bookkeeping from the donor is out of scope for this story.
ponytail: port the donor's retry+RPD throttling when analysis runs at real volume.
"""
from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
from typing import Protocol
from sqlalchemy import func, select
from sqlalchemy.orm import sessionmaker
from photo_pipeline.integrations import exiftool
from photo_pipeline.models import AnalysisResult, Asset, SafetyReview
from photo_pipeline.services import hashing
from photo_pipeline.services.safety import SFW
MODEL = "gemini-2.5-flash"
PROMPT_VERSION = "1"
RESULT_FIELDS = (
"description",
"tags",
"people_count",
"setting",
"time_of_day",
"season",
"mood",
"location_hint",
"approx_year",
)
class VisionProvider(Protocol):
def analyze(self, path: str, *, album_hint: str) -> dict:
"""Return the analysis fields for one image. Raises on unrecoverable error."""
class AnalysisError(Exception):
pass
def _now() -> datetime:
return datetime.now(timezone.utc)
class AnalysisService:
def __init__(self, session_factory: sessionmaker, *, provider: VisionProvider | None = None) -> None:
self._session_factory = session_factory
self._provider = provider
def _sfw_asset_ids(self, session) -> set[str]:
"""Asset ids whose latest safety decision is ``sfw`` — the ONLY assets that
may reach the provider."""
latest: dict[str, str | None] = {}
for review in session.scalars(select(SafetyReview).order_by(SafetyReview.created_at)):
latest[review.asset_id] = review.decision
return {aid for aid, decision in latest.items() if decision == SFW}
def eligible_asset_ids(self) -> list[str]:
"""Confirmed-SFW canonical active assets without a completed analysis."""
with self._session_factory() as session:
sfw = self._sfw_asset_ids(session)
if not sfw:
return []
assets = session.scalars(
select(Asset).where(
Asset.id.in_(sfw),
Asset.canonical_asset_id.is_(None),
Asset.availability_state == "active",
Asset.current_path.is_not(None),
)
)
done = set(
session.scalars(
select(AnalysisResult.asset_id).where(AnalysisResult.status == "analyzed")
)
)
return [a.id for a in assets if a.id not in done]
def counts(self) -> dict[str, int]:
with self._session_factory() as session:
sfw = self._sfw_asset_ids(session)
rows = dict(
session.execute(
select(AnalysisResult.status, func.count()).group_by(AnalysisResult.status)
).all()
)
analyzed = int(rows.get("analyzed", 0))
errored = int(rows.get("error", 0))
return {
"eligible": len(sfw),
"analyzed": analyzed,
"error": errored,
"pending": max(len(sfw) - analyzed - errored, 0),
}
def run(self, asset_ids: list[str] | None = None) -> dict:
"""Analyze the given assets (default: all eligible). Enforces the gate.
Returns ``{analyzed, skipped, errors}``. ``skipped`` counts assets that were
requested but are not confirmed SFW — the provider is never called for them.
"""
with self._session_factory() as session:
sfw = self._sfw_asset_ids(session)
paths = {
a.id: a.current_path
for a in session.scalars(select(Asset).where(Asset.canonical_asset_id.is_(None)))
if a.current_path
}
requested = asset_ids if asset_ids is not None else self.eligible_asset_ids()
provider = self._provider or _default_provider()
analyzed = skipped = errors = 0
for asset_id in requested:
if asset_id not in sfw:
# Gate: not confirmed SFW → never construct a provider request.
self._store(asset_id, status="skipped_nsfw", result=None, error=None, tokens=0, raw="")
skipped += 1
continue
path = paths.get(asset_id)
if not path:
skipped += 1
continue
try:
result = provider.analyze(path, album_hint=_album_hint(path))
except Exception as error: # provider/validation failure is per-asset
self._store(asset_id, status="error", result=None, error=str(error), tokens=0, raw="")
errors += 1
continue
self._store(
asset_id,
status="analyzed",
result=result,
error=None,
tokens=int(result.get("_tokens", 0)) if isinstance(result, dict) else 0,
raw=json.dumps(result, ensure_ascii=False),
)
_write_analysis_exif(path, result)
analyzed += 1
return {"analyzed": analyzed, "skipped": skipped, "errors": errors}
def _store(self, asset_id, *, status, result, error, tokens, raw) -> None:
now = _now()
with self._session_factory() as session:
row = session.get(AnalysisResult, asset_id) or AnalysisResult(asset_id=asset_id)
row.status = status
row.error_message = error
row.model = MODEL
row.prompt_version = PROMPT_VERSION
row.tokens_total = tokens
row.raw_response = raw or None
if status == "analyzed" and isinstance(result, dict):
row.description = result.get("description")
row.tags = json.dumps(result.get("tags", []), ensure_ascii=False)
row.people_count = result.get("people_count")
row.setting = result.get("setting")
row.time_of_day = result.get("time_of_day")
row.season = result.get("season")
row.mood = result.get("mood")
row.location_hint = result.get("location_hint")
row.approx_year = result.get("approx_year")
row.analyzed_at = now
row.exif_written_at = now
session.add(row)
session.commit()
def get(self, asset_id: str) -> dict | None:
with self._session_factory() as session:
row = session.get(AnalysisResult, asset_id)
return _result_dict(row) if row else None
def _album_hint(path: str) -> str:
from pathlib import Path
return Path(path).parent.name
def _write_analysis_exif(path: str, result: dict) -> None:
"""Additive analysis keywords into EXIF (Keywords/Subject), preserving safety
and user keywords. The donor also wrote a managed caption; only keywords are
written here via the shared adapter.
ponytail: add the managed ``AI:`` caption segment + read-back preservation check
when the analysis EXIF checkpoint is hardened."""
tags = result.get("tags") if isinstance(result, dict) else None
if tags:
exiftool.apply_keywords(path, add=[str(t) for t in tags])
def _result_dict(row: AnalysisResult) -> dict:
data = {field: getattr(row, field) for field in RESULT_FIELDS}
data["tags"] = json.loads(row.tags) if row.tags else []
data.update(
asset_id=row.asset_id,
status=row.status,
model=row.model,
prompt_version=row.prompt_version,
tokens_total=row.tokens_total,
error_message=row.error_message,
)
return data
def _default_provider() -> VisionProvider:
return OpenAIVisionProvider()
class OpenAIVisionProvider:
"""The real provider: an OpenAI-compatible vision call (Gemini by default).
Extracted from photo_analyzer.analyze_image. Constructed lazily from env
(``OPENAI_API_KEY`` / ``OPENAI_BASE_URL``); never used in tests, which inject a
fake. Kept intentionally thin — no retry/throttle bookkeeping (see module note).
"""
def __init__(self, *, model: str = MODEL, client=None) -> None:
self._model = model
self._client = client
def _ensure_client(self):
if self._client is None:
from openai import OpenAI
self._client = OpenAI()
return self._client
def analyze(self, path: str, *, album_hint: str) -> dict:
b64, mime = _prepare_image(path)
prompt = ANALYSIS_PROMPT + (
f'\n\nAlbum hint: this photo is filed in a folder named "{album_hint}". '
f'Folder names often contain the place and/or year — use it to inform '
f'"location_hint" and "approx_year", but trust the image if they conflict.'
)
response = self._ensure_client().chat.completions.create(
model=self._model,
max_tokens=4096,
messages=[
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}},
{"type": "text", "text": prompt},
],
}
],
)
raw = (response.choices[0].message.content or "").strip()
if raw.startswith("```"):
raw = raw.split("```")[1]
raw = raw[4:] if raw.startswith("json") else raw
raw = raw.strip()
result = json.loads(raw)
usage = getattr(response, "usage", None)
result["_tokens"] = usage.total_tokens if usage else 0
return result
ANALYSIS_PROMPT = (
"Analyze this photograph and return ONLY a JSON object with keys: description "
"(one clear sentence), tags (8-12 specific keywords), people_count (integer), "
"setting, time_of_day, season, mood, location_hint (or null), approx_year "
"(integer or null)."
)
def _prepare_image(path: str) -> tuple[str, str]:
import base64
from io import BytesIO
from PIL import Image
with Image.open(path) as image:
image = image.convert("RGB")
image.thumbnail((1024, 1024))
buffer = BytesIO()
image.save(buffer, format="JPEG", quality=85)
return base64.b64encode(buffer.getvalue()).decode(), "image/jpeg"