410 lines
16 KiB
Python
410 lines
16 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 os
|
|
from datetime import datetime, timezone
|
|
from typing import Protocol
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from photo_pipeline import path_policy
|
|
from photo_pipeline.models import AnalysisResult, Asset
|
|
from photo_pipeline.services import exif_checkpoint
|
|
from photo_pipeline.services.safety import SFW, latest_reviews
|
|
|
|
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,
|
|
library_roots: tuple = (),
|
|
) -> None:
|
|
self._session_factory = session_factory
|
|
self._provider = provider
|
|
self._roots = tuple(library_roots)
|
|
|
|
def _sfw_asset_ids(self, session) -> set[str]:
|
|
"""Asset ids whose latest safety decision is ``sfw`` — the ONLY assets that
|
|
may reach the provider.
|
|
|
|
The "latest row wins" rule is applied in SQL (US07-06); loading every review
|
|
to fold it in Python made the gate cost grow with the review history rather
|
|
than with the work being gated.
|
|
"""
|
|
latest = latest_reviews().subquery()
|
|
return set(
|
|
session.scalars(select(latest.c.asset_id).where(latest.c.decision == SFW))
|
|
)
|
|
|
|
def _sfw_count(self, session) -> int:
|
|
"""How many assets the gate currently allows, without listing them."""
|
|
latest = latest_reviews().subquery()
|
|
return int(
|
|
session.scalar(
|
|
select(func.count()).select_from(latest).where(latest.c.decision == SFW)
|
|
)
|
|
or 0
|
|
)
|
|
|
|
def _is_still_sfw(self, asset_id: str) -> bool:
|
|
"""Re-read the current safety decision straight from the database."""
|
|
with self._session_factory() as session:
|
|
return asset_id in self._sfw_asset_ids(session)
|
|
|
|
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, *, eligible: int | None = None) -> dict[str, int]:
|
|
"""Analysis progress. ``eligible`` may be passed by a caller that has just
|
|
counted confirmed-SFW assets, so the workflow home does not resolve the
|
|
latest decision of every asset twice on one page load (US07-06)."""
|
|
with self._session_factory() as session:
|
|
eligible = self._sfw_count(session) if eligible is None else eligible
|
|
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": eligible,
|
|
"analyzed": analyzed,
|
|
"error": errored,
|
|
"pending": max(eligible - 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
|
|
# Second gate, at the moment of use: the database says where the file
|
|
# was, the filesystem decides what that name means now. A link swapped
|
|
# under an asset after the scan would otherwise send bytes from outside
|
|
# the library — the one place that leaves this machine (US07-02).
|
|
try:
|
|
path = str(path_policy.resolve_in_roots(self._roots, path))
|
|
except path_policy.PathPolicyError as error:
|
|
self._store(
|
|
asset_id, status="error", result=None, error=str(error), tokens=0, raw=""
|
|
)
|
|
errors += 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
|
|
# Third gate, after the call: a provider request takes seconds, and the
|
|
# reviewer may have flipped this asset to NSFW while it was in flight.
|
|
# The result describes an asset that is no longer analysable, so it is
|
|
# discarded — not stored, and above all not written into its EXIF
|
|
# (concept §18 scenario 7, US07-04).
|
|
if not self._is_still_sfw(asset_id):
|
|
self._store(
|
|
asset_id,
|
|
status="skipped_nsfw",
|
|
result=None,
|
|
error="the safety decision changed while analysis was in flight",
|
|
tokens=0,
|
|
raw="",
|
|
)
|
|
skipped += 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),
|
|
)
|
|
self._write_analysis_exif(asset_id, 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
|
|
session.add(row)
|
|
session.commit()
|
|
|
|
def _write_analysis_exif(self, asset_id: str, path: str, result: dict) -> None:
|
|
"""The analysis EXIF checkpoint: additive keywords, then prove the rest held.
|
|
|
|
Additive by design — safety keywords and the user's own keywords are merged
|
|
with, never replaced (concept §3). ``exif_written_at`` is set only when the
|
|
read-back verified both the new keywords and every field this stage does not
|
|
own; a divergent result is recorded and left for a human (US07-03).
|
|
ponytail: the managed ``AI:`` caption segment, once captions are owned here.
|
|
"""
|
|
tags = tuple(str(tag) for tag in (result.get("tags") or []))
|
|
if not tags:
|
|
return
|
|
checkpoint = exif_checkpoint.run(path, add=tags)
|
|
exif_checkpoint.record(
|
|
self._session_factory,
|
|
asset_id=asset_id,
|
|
stage="analysis",
|
|
result=checkpoint,
|
|
add=tags,
|
|
)
|
|
if not checkpoint.verified:
|
|
return
|
|
with self._session_factory() as session:
|
|
row = session.get(AnalysisResult, asset_id)
|
|
if row is not None:
|
|
row.exif_written_at = checkpoint.verified_at
|
|
asset = session.get(Asset, asset_id)
|
|
if asset is not None and checkpoint.sha256:
|
|
# The bytes changed when the container was rewritten; upload must use
|
|
# the hash of what is actually on disk now (concept §3), and the
|
|
# recorded size has to move with it (US07-07).
|
|
asset.current_sha256 = checkpoint.sha256
|
|
if checkpoint.byte_size is not None:
|
|
asset.byte_size = checkpoint.byte_size
|
|
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 _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:
|
|
# Test seam (concept §18: deterministic fakes replace the vision edge, enabled
|
|
# only by test configuration). When this env var names a writable log file, the
|
|
# worker/API use a fake that records every analyzed path — so the SFW-only gate
|
|
# can be asserted end-to-end through the real integration layer — instead of the
|
|
# real OpenAI-compatible call. Never set in production.
|
|
log_path = os.environ.get("PHOTO_PIPELINE_FAKE_VISION_LOG")
|
|
if log_path:
|
|
return _RecordingFakeVision(log_path)
|
|
return OpenAIVisionProvider()
|
|
|
|
|
|
class _RecordingFakeVision:
|
|
"""Deterministic vision fake for end-to-end tests. Appends every analyzed file
|
|
path to its log so a test can prove NSFW assets never reach the provider, and
|
|
raises for a path whose stem contains ``boom`` to exercise per-asset error
|
|
handling. Constructed only when ``PHOTO_PIPELINE_FAKE_VISION_LOG`` is set."""
|
|
|
|
def __init__(self, log_path: str) -> None:
|
|
self._log_path = log_path
|
|
|
|
def analyze(self, path: str, *, album_hint: str) -> dict:
|
|
with open(self._log_path, "a", encoding="utf-8") as handle:
|
|
handle.write(path + "\n")
|
|
if "boom" in os.path.splitext(os.path.basename(path))[0]:
|
|
raise AnalysisError("fake vision failure")
|
|
return {
|
|
"description": f"a deterministic scene in {album_hint}",
|
|
"tags": ["fixture", "deterministic"],
|
|
"people_count": 1,
|
|
"setting": "outdoor",
|
|
"time_of_day": "day",
|
|
"season": "summer",
|
|
"mood": "calm",
|
|
"location_hint": None,
|
|
"approx_year": None,
|
|
"_tokens": 7,
|
|
}
|
|
|
|
|
|
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"
|