371 lines
16 KiB
Python
371 lines
16 KiB
Python
"""ProposalService — AI-assisted, versioned album naming proposals (US03-03).
|
|
|
|
Generates one durable proposal per album from the US03-01 evidence, names it through
|
|
the US03-02 policy, and keeps rationale, confidence, evidence version, model/prompt
|
|
version, edits, and approval state durable. **No rename ever happens here**: approval
|
|
records intent, and Phase D performs the filesystem work.
|
|
|
|
Provider minimality — the adapter receives only a compact aggregated summary
|
|
(album/folder name, date, top tags, top locations, counts and a few descriptions).
|
|
Asset IDs, file paths, and raw model responses are never sent, so a proposal cannot
|
|
leak per-photo identity into the naming provider.
|
|
|
|
Failure handling is explicit and safely retryable: a malformed response, a rate limit,
|
|
or a provider error stores ``status='error'`` with an ``error_code`` and leaves the
|
|
album eligible for another ``generate`` call. Retrying never duplicates a proposal —
|
|
rows are keyed by album, so a regeneration updates the existing row and bumps its
|
|
version.
|
|
|
|
Staleness: a proposal records the ``evidence_version`` it was generated from. When the
|
|
album's current evidence version differs, the proposal reads back as ``stale`` and
|
|
approval is refused until it is regenerated.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Protocol
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from photo_pipeline.models import AlbumProposal
|
|
from photo_pipeline.services.albums import AlbumService
|
|
from photo_pipeline.services.naming import NamingPolicy, build_fields, render_name, validate
|
|
|
|
MODEL = "gemini-2.5-flash"
|
|
PROMPT_VERSION = "1"
|
|
# Bounds on what the provider is allowed to see, so the summary stays minimal.
|
|
MAX_TAGS = 12
|
|
MAX_LOCATIONS = 3
|
|
MAX_DESCRIPTIONS = 5
|
|
|
|
PENDING = "pending"
|
|
PROPOSED = "proposed"
|
|
EDITED = "edited"
|
|
APPROVED = "approved"
|
|
ERROR = "error"
|
|
|
|
|
|
class ProposalProvider(Protocol):
|
|
def propose(self, summary: dict) -> dict:
|
|
"""Return ``{name, rationale, confidence}`` for one album summary."""
|
|
|
|
|
|
class ProposalError(RuntimeError):
|
|
"""Invalid request against a proposal (unknown album, bad name, stale evidence)."""
|
|
|
|
|
|
class ConflictError(RuntimeError):
|
|
"""Optimistic version check failed; the proposal changed since it was read."""
|
|
|
|
|
|
class ProviderError(RuntimeError):
|
|
"""The naming provider failed. ``code`` distinguishes retryable causes."""
|
|
|
|
def __init__(self, code: str, message: str = "") -> None:
|
|
super().__init__(message or code)
|
|
self.code = code
|
|
|
|
|
|
class RateLimited(ProviderError):
|
|
def __init__(self, message: str = "rate limited") -> None:
|
|
super().__init__("rate_limited", message)
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _default_provider() -> ProposalProvider | None:
|
|
"""The provider a server process uses when none is injected.
|
|
|
|
Test seam (concept §18: deterministic fakes at the integration boundary, enabled
|
|
only by test configuration). When ``PHOTO_PIPELINE_FAKE_NAMING_LOG`` names a
|
|
writable file, the API uses the recording fake below so end-to-end tests can
|
|
assert exactly what the provider was shown. Otherwise there is no naming
|
|
provider yet and generation falls back to the deterministic policy name.
|
|
"""
|
|
log_path = os.environ.get("PHOTO_PIPELINE_FAKE_NAMING_LOG")
|
|
return _RecordingFakeNaming(log_path) if log_path else None
|
|
|
|
|
|
class _RecordingFakeNaming:
|
|
"""Deterministic naming fake for end-to-end tests. Appends every summary it is
|
|
shown to its log (one JSON object per line) so a test can assert the provider's
|
|
inputs, and returns a stable name derived from the evidence. An album whose
|
|
folder name contains ``boom`` yields a malformed response, exercising the
|
|
quarantine path through the real service."""
|
|
|
|
model_name = "fake-naming"
|
|
|
|
def __init__(self, log_path: str) -> None:
|
|
self._log_path = log_path
|
|
|
|
def propose(self, summary: dict) -> dict:
|
|
with open(self._log_path, "a", encoding="utf-8") as handle:
|
|
handle.write(json.dumps(summary, ensure_ascii=False, sort_keys=True) + "\n")
|
|
folder = summary.get("folder_name") or "album"
|
|
if "boom" in folder:
|
|
return {"rationale": "deliberately missing a name"}
|
|
parts = [str(summary["dominant_year"])] if summary.get("dominant_year") else []
|
|
parts += (summary.get("locations") or [])[:1]
|
|
parts.append(folder.title())
|
|
return {
|
|
"name": " — ".join(parts),
|
|
"rationale": f"Named from {summary.get('analyzed_count', 0)} analysed photos.",
|
|
"confidence": 0.9,
|
|
}
|
|
|
|
|
|
def evidence_summary(evidence: dict) -> dict:
|
|
"""The minimum aggregated evidence a naming provider may see. Bounded and free of
|
|
asset IDs and file paths."""
|
|
return {
|
|
"folder_name": evidence.get("folder_name"),
|
|
"parent_path": evidence.get("parent_path"),
|
|
"photo_count": evidence.get("asset_count"),
|
|
"analyzed_count": evidence.get("analyzed_count"),
|
|
"dominant_year": evidence.get("dominant_year"),
|
|
"year_conflict": evidence.get("year_conflict"),
|
|
"years": [item["value"] for item in (evidence.get("years") or [])],
|
|
"top_tags": [item["value"] for item in (evidence.get("tags") or [])[:MAX_TAGS]],
|
|
"locations": [item["value"] for item in (evidence.get("locations") or [])[:MAX_LOCATIONS]],
|
|
"settings": [item["value"] for item in (evidence.get("settings") or [])],
|
|
"descriptions": (evidence.get("descriptions") or [])[:MAX_DESCRIPTIONS],
|
|
}
|
|
|
|
|
|
def validate_response(raw: dict) -> dict:
|
|
"""Validate a provider response into ``{name, rationale, confidence}``.
|
|
|
|
Raises ``ProviderError('malformed_response')`` for anything unusable, so a bad
|
|
model reply is quarantined rather than persisted as a name.
|
|
"""
|
|
if not isinstance(raw, dict):
|
|
raise ProviderError("malformed_response", "response is not an object")
|
|
name = raw.get("name")
|
|
if not isinstance(name, str) or not name.strip():
|
|
raise ProviderError("malformed_response", "missing or empty 'name'")
|
|
rationale = raw.get("rationale")
|
|
if rationale is not None and not isinstance(rationale, str):
|
|
raise ProviderError("malformed_response", "'rationale' must be a string")
|
|
confidence = raw.get("confidence")
|
|
if confidence is not None:
|
|
if isinstance(confidence, bool) or not isinstance(confidence, (int, float)):
|
|
raise ProviderError("malformed_response", "'confidence' must be a number")
|
|
confidence = float(confidence)
|
|
if not 0.0 <= confidence <= 1.0:
|
|
raise ProviderError("malformed_response", "'confidence' must be within 0..1")
|
|
return {"name": name.strip(), "rationale": rationale, "confidence": confidence}
|
|
|
|
|
|
class ProposalService:
|
|
def __init__(
|
|
self,
|
|
session_factory: sessionmaker,
|
|
*,
|
|
provider: ProposalProvider | None = None,
|
|
library_roots: tuple = (),
|
|
policy: NamingPolicy | None = None,
|
|
) -> None:
|
|
self._session_factory = session_factory
|
|
self._provider = provider if provider is not None else _default_provider()
|
|
self._albums = AlbumService(session_factory, library_roots=library_roots)
|
|
self._policy = policy or NamingPolicy()
|
|
|
|
# ── evidence ──────────────────────────────────────────────────────────────
|
|
|
|
def _evidence_by_album(self) -> dict[str, dict]:
|
|
page = self._albums.aggregate_evidence()
|
|
return {folder["album"]: folder for folder in page["folders"]}
|
|
|
|
# ── generation ────────────────────────────────────────────────────────────
|
|
|
|
def generate(self, albums: list[str] | None = None) -> dict:
|
|
"""Generate (or regenerate) proposals. Returns ``{proposed, errors, skipped}``.
|
|
|
|
Retry-safe: one row per album, updated in place. A provider failure records an
|
|
error and leaves the album eligible for another attempt.
|
|
"""
|
|
evidence_by_album = self._evidence_by_album()
|
|
targets = albums if albums is not None else sorted(evidence_by_album)
|
|
proposed = errors = skipped = 0
|
|
|
|
for album in targets:
|
|
evidence = evidence_by_album.get(album)
|
|
if evidence is None:
|
|
skipped += 1
|
|
continue
|
|
try:
|
|
result = self._propose_one(evidence)
|
|
except ProviderError as error:
|
|
self._store_error(album, error.code, str(error), evidence["version"])
|
|
errors += 1
|
|
continue
|
|
self._store_proposal(album, result, evidence["version"])
|
|
proposed += 1
|
|
return {"proposed": proposed, "errors": errors, "skipped": skipped}
|
|
|
|
def _propose_one(self, evidence: dict) -> dict:
|
|
summary = evidence_summary(evidence)
|
|
if self._provider is None:
|
|
# No provider configured: fall back to the deterministic policy name, so
|
|
# proposals still work offline. Rationale says so; confidence stays low.
|
|
name = render_name(build_fields(evidence), self._policy)
|
|
if not name:
|
|
raise ProviderError("no_evidence", "no evidence to build a name from")
|
|
return {
|
|
"name": name,
|
|
"rationale": "Generated from folder evidence using the naming template.",
|
|
"confidence": 0.3,
|
|
"raw": None,
|
|
}
|
|
raw = self._provider.propose(summary)
|
|
validated = validate_response(raw)
|
|
validated["raw"] = json.dumps(raw, ensure_ascii=False, default=str)
|
|
return validated
|
|
|
|
def _store_proposal(self, album: str, result: dict, evidence_version: str) -> None:
|
|
# Names are always run through the naming policy before they are stored, so a
|
|
# model can never introduce a path separator or reserved name.
|
|
checked = validate(result["name"], policy=self._policy)
|
|
name = checked.name or (checked.suggestions[0] if checked.suggestions else "")
|
|
with self._session_factory() as session:
|
|
row = self._row(session, album) or AlbumProposal(id=str(uuid.uuid4()), album=album)
|
|
row.proposed_name = name
|
|
row.final_name = None # a regeneration clears a previous edit
|
|
row.rationale = result.get("rationale")
|
|
row.confidence = result.get("confidence")
|
|
row.status = PROPOSED
|
|
row.error_code = row.error_message = None
|
|
row.evidence_version = evidence_version
|
|
# Record the model that actually produced the name, so a fake or the
|
|
# offline policy fallback is never filed under the real model's name.
|
|
row.model = getattr(self._provider, "model_name", MODEL) if self._provider else "policy"
|
|
row.prompt_version = PROMPT_VERSION
|
|
row.raw_response = result.get("raw")
|
|
row.approved_at = None
|
|
row.version = (row.version or 0) + 1
|
|
row.updated_at = _now()
|
|
session.add(row)
|
|
session.commit()
|
|
|
|
def _store_error(self, album: str, code: str, message: str, evidence_version: str) -> None:
|
|
with self._session_factory() as session:
|
|
row = self._row(session, album) or AlbumProposal(id=str(uuid.uuid4()), album=album)
|
|
row.status = ERROR
|
|
row.error_code = code
|
|
row.error_message = message[:500]
|
|
row.evidence_version = evidence_version
|
|
row.version = (row.version or 0) + 1
|
|
row.updated_at = _now()
|
|
session.add(row)
|
|
session.commit()
|
|
|
|
# ── reads ─────────────────────────────────────────────────────────────────
|
|
|
|
@staticmethod
|
|
def _row(session, album: str) -> AlbumProposal | None:
|
|
return session.scalar(select(AlbumProposal).where(AlbumProposal.album == album))
|
|
|
|
def get(self, album: str) -> dict | None:
|
|
current = self._evidence_by_album().get(album)
|
|
with self._session_factory() as session:
|
|
row = self._row(session, album)
|
|
if row is None:
|
|
return None
|
|
return _as_dict(row, current["version"] if current else None)
|
|
|
|
def list(self, *, status: str | None = None) -> dict:
|
|
versions = {
|
|
album: evidence["version"] for album, evidence in self._evidence_by_album().items()
|
|
}
|
|
with self._session_factory() as session:
|
|
rows = list(session.scalars(select(AlbumProposal).order_by(AlbumProposal.album)))
|
|
items = [_as_dict(row, versions.get(row.album)) for row in rows]
|
|
if status:
|
|
items = [item for item in items if item["status"] == status]
|
|
return {"items": items, "total": len(items)}
|
|
|
|
# ── edit / approve ────────────────────────────────────────────────────────
|
|
|
|
def edit(self, album: str, name: str, *, expected_version: int) -> dict:
|
|
"""Replace the proposed name with the user's own. Validated by the naming
|
|
policy; an invalid name is rejected with its structured issues."""
|
|
checked = validate(name, policy=self._policy)
|
|
if not checked.name:
|
|
raise ProposalError(f"name is empty after sanitization: {checked.issues}")
|
|
with self._session_factory() as session:
|
|
row = self._require(session, album, expected_version)
|
|
row.final_name = checked.name
|
|
row.status = EDITED
|
|
row.approved_at = None
|
|
row.version += 1
|
|
row.updated_at = _now()
|
|
session.add(row)
|
|
session.commit()
|
|
return self.get(album)
|
|
|
|
def approve(self, album: str, *, expected_version: int) -> dict:
|
|
"""Approve the current proposal. Validates the latest version and that the
|
|
evidence has not moved on. Records intent only — no rename occurs."""
|
|
current = self._evidence_by_album().get(album)
|
|
with self._session_factory() as session:
|
|
row = self._require(session, album, expected_version)
|
|
if row.status == ERROR:
|
|
raise ProposalError(f"proposal for {album!r} is in error state")
|
|
name = row.final_name or row.proposed_name
|
|
if not name:
|
|
raise ProposalError(f"proposal for {album!r} has no name to approve")
|
|
if current is not None and row.evidence_version != current["version"]:
|
|
raise ProposalError(
|
|
f"proposal for {album!r} is stale; regenerate it before approving"
|
|
)
|
|
row.status = APPROVED
|
|
row.approved_at = _now()
|
|
row.version += 1
|
|
row.updated_at = _now()
|
|
session.add(row)
|
|
session.commit()
|
|
return self.get(album)
|
|
|
|
def _require(self, session, album: str, expected_version: int) -> AlbumProposal:
|
|
row = self._row(session, album)
|
|
if row is None:
|
|
raise ProposalError(f"unknown album proposal {album!r}")
|
|
if row.version != expected_version:
|
|
raise ConflictError(
|
|
f"proposal for {album!r} is at version {row.version}, expected {expected_version}"
|
|
)
|
|
return row
|
|
|
|
|
|
def _as_dict(row: AlbumProposal, current_evidence_version: str | None) -> dict:
|
|
stale = (
|
|
current_evidence_version is not None
|
|
and row.evidence_version is not None
|
|
and row.evidence_version != current_evidence_version
|
|
)
|
|
return {
|
|
"album": row.album,
|
|
"proposed_name": row.proposed_name,
|
|
"final_name": row.final_name,
|
|
"name": row.final_name or row.proposed_name,
|
|
"rationale": row.rationale,
|
|
"confidence": row.confidence,
|
|
"status": row.status,
|
|
"error_code": row.error_code,
|
|
"error_message": row.error_message,
|
|
"evidence_version": row.evidence_version,
|
|
"current_evidence_version": current_evidence_version,
|
|
"stale": stale,
|
|
"model": row.model,
|
|
"prompt_version": row.prompt_version,
|
|
"version": row.version,
|
|
"approved_at": row.approved_at.isoformat() if row.approved_at else None,
|
|
}
|