"""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, }