"""AlbumService — aggregate per-folder evidence for album proposals (US03-01). Read-only. For each leaf folder it summarises the *already stored* analysis and safety evidence of the canonical, active assets living there, so a later stage can name the folder without resending images or exposing raw model responses. It writes nothing and generates no names (that is US03-02/03). Folder identity is the full parent path of an asset's ``current_path`` — not just the leaf name — so two folders that share a leaf never merge. Aggregation is deterministic (everything sorted, no clock/random) and each folder's evidence carries a content ``version`` derived only from the inputs that could change it, so any relevant asset change (new analysis, decision change, move, or becoming a duplicate) invalidates exactly that folder's version and nothing else. Documented evidence rules (acceptance criterion 2): - **Duplicate (non-canonical) assets** — excluded entirely; the canonical carries the evidence. - **Missing / archived assets** — excluded: only ``availability_state == 'active'`` assets with a ``current_path`` belong to an active folder. - **NSFW assets** — counted in the folder total and reported as ``nsfw_count``, but contribute NO analysis-derived evidence (tags, description, setting, location, year, people). They are never analysed, and their content must not shape a name beyond mere presence. - **Undecided / deferred / not-yet-analysed assets** — counted in the folder total and reported as ``pending_count``; they contribute no evidence until confirmed SFW and analysed. - **Only confirmed-SFW analysed assets** shape the descriptive evidence. - **Missing fields** (null/empty) simply do not contribute. - **Conflicting fields** (e.g. several ``approx_year`` values) are never silently collapsed: the full sorted distribution is returned, plus a ``dominant_year`` that is set only when one value strictly outnumbers the rest; ties set it to ``None`` and raise ``year_conflict``. Reuses ``_tags`` from :mod:`photo_pipeline.services.library` (the shared JSON-tag decode). ponytail: neighbouring-folder names/date ranges and user naming conventions (concept §7 inputs) are proposal-generation concerns — add them in US03-03 when a name is actually produced. """ from __future__ import annotations import hashlib import json from collections import Counter from pathlib import Path from sqlalchemy import select from sqlalchemy.orm import sessionmaker from photo_pipeline.models import AnalysisResult, Asset, SafetyReview from photo_pipeline.services.library import _tags SFW = "sfw" NSFW = "nsfw" ANALYZED = "analyzed" # Cap the descriptions list so a huge folder can't produce an unbounded payload; # tags/facets are already bounded by their distinct-value count. MAX_DESCRIPTIONS = 50 def album_label(path: str, library_roots: tuple[Path, ...] = ()) -> str: """The canonical album identity: the leaf folder directly containing ``path``, shown as a path relative to its library root so nested albums stay distinct (``Urlaub/Rom`` vs ``Urlaub/Venedig``); ``(root)`` for a photo directly in a root, and the bare parent name for a photo outside every configured root. Ported from ``photo_analyzer.album_label`` (donor_ledger.yaml: pa-album-label); ``webapp/query.album_of`` was a verbatim copy (wa-album-of). This is the single implementation for stats, proposals, and folder-as-album upload. """ parent = Path(path).parent for root in library_roots: try: rel = parent.relative_to(root) return "(root)" if str(rel) == "." else str(rel) except ValueError: continue return parent.name or str(parent) def _counts(counter: Counter) -> list[dict]: """Deterministic ``[{value, count}]``, most frequent first, ties broken by value.""" return [ {"value": value, "count": count} for value, count in sorted(counter.items(), key=lambda kv: (-kv[1], str(kv[0]))) ] def _dominant(counter: Counter): """The single strictly-most-frequent value, or ``(None, conflict)``. ``conflict`` is true when more than one distinct value is present and no strict winner exists.""" if not counter: return None, False ranked = counter.most_common() if len(ranked) == 1: return ranked[0][0], False top_value, top_count = ranked[0] if top_count > ranked[1][1]: return top_value, False return None, True class AlbumService: def __init__( self, session_factory: sessionmaker, *, library_roots: tuple[Path, ...] = () ) -> None: self._session_factory = session_factory self._library_roots = tuple(Path(root) for root in library_roots) def _album_of(self, path: str) -> str: return album_label(path, self._library_roots) def _latest_decisions(self, session) -> dict[str, str | None]: """asset_id -> latest safety decision (``sfw``/``nsfw``/``deferred``/None).""" latest: dict[str, str | None] = {} for review in session.scalars(select(SafetyReview).order_by(SafetyReview.created_at)): latest[review.asset_id] = review.decision return latest def folder_count(self) -> int: """Number of distinct active-canonical folders (for paging callers).""" with self._session_factory() as session: paths = session.scalars( select(Asset.current_path).where( Asset.canonical_asset_id.is_(None), Asset.availability_state == "active", Asset.current_path.is_not(None), ) ) return len({self._album_of(p) for p in paths}) def aggregate_evidence(self, *, offset: int = 0, limit: int | None = None) -> dict: """Return a page of per-folder evidence, folders sorted by path. ``{folders: [...], total: int, offset, limit}``. ``limit=None`` returns all. """ with self._session_factory() as session: decisions = self._latest_decisions(session) rows = list( session.execute( select(Asset.id, Asset.current_path, AnalysisResult) .outerjoin(AnalysisResult, AnalysisResult.asset_id == Asset.id) .where( Asset.canonical_asset_id.is_(None), Asset.availability_state == "active", Asset.current_path.is_not(None), ) .order_by(Asset.current_path, Asset.id) ) ) folders: dict[str, list] = {} for asset_id, path, result in rows: folders.setdefault(self._album_of(path), []).append((asset_id, path, result)) ordered = sorted(folders) total = len(ordered) page = ordered[offset:] if limit is None else ordered[offset : offset + limit] evidence = [self._folder_evidence(folder, folders[folder], decisions) for folder in page] return {"folders": evidence, "total": total, "offset": offset, "limit": limit} def _folder_evidence(self, folder: str, members: list, decisions: dict) -> dict: descriptions: list[str] = [] tags: Counter = Counter() settings: Counter = Counter() time_of_day: Counter = Counter() seasons: Counter = Counter() locations: Counter = Counter() people: Counter = Counter() years: Counter = Counter() analyzed_count = nsfw_count = pending_count = 0 fingerprint: list[tuple] = [] for asset_id, path, result in members: decision = decisions.get(asset_id) status = result.status if result is not None else None # Fingerprint: every input that could change this folder's evidence. fingerprint.append( ( asset_id, path, decision, status, result.description if result is not None else None, result.tags if result is not None else None, result.people_count if result is not None else None, result.setting if result is not None else None, result.time_of_day if result is not None else None, result.season if result is not None else None, _clean(result.location_hint) if result is not None else None, result.approx_year if result is not None else None, ) ) if decision == NSFW: nsfw_count += 1 continue eligible = decision == SFW and status == ANALYZED and result is not None if not eligible: pending_count += 1 continue analyzed_count += 1 if result.description and result.description.strip(): descriptions.append(result.description.strip()) for tag in _tags(result.tags): tags[tag] += 1 if result.setting: settings[result.setting] += 1 if result.time_of_day: time_of_day[result.time_of_day] += 1 if result.season: seasons[result.season] += 1 location = _clean(result.location_hint) if location: locations[location] += 1 if result.people_count is not None: people["3+" if result.people_count >= 3 else str(result.people_count)] += 1 if result.approx_year is not None: years[result.approx_year] += 1 dominant_year, year_conflict = _dominant(years) # ``folder`` is the album label (identity): a library-relative path like # ``Urlaub/Rom``, or ``(root)``. Name is its leaf; parent is the prefix. parent = folder.rsplit("/", 1)[0] if "/" in folder else "" name = folder.rsplit("/", 1)[-1] return { "album": folder, "folder_name": name, "parent_path": parent, "asset_count": len(members), "analyzed_count": analyzed_count, "nsfw_count": nsfw_count, "pending_count": pending_count, "descriptions": sorted(descriptions)[:MAX_DESCRIPTIONS], "tags": _counts(tags), "settings": _counts(settings), "time_of_day": _counts(time_of_day), "seasons": _counts(seasons), "locations": _counts(locations), "people_counts": _counts(people), "years": _counts(years), "dominant_year": dominant_year, "year_conflict": year_conflict, "version": _version(fingerprint), } def _clean(location: str | None) -> str | None: """Drop empty/``null`` location hints (the model emits the string ``"null"``).""" if not location: return None trimmed = location.strip() if not trimmed or trimmed.lower() == "null": return None return trimmed def _version(fingerprint: list[tuple]) -> str: """Deterministic content hash of a folder's evidence inputs. Independent of row order (the inputs are sorted) so only a real change moves it.""" payload = json.dumps(sorted(fingerprint, key=lambda t: t[0]), ensure_ascii=False) return hashlib.sha256(payload.encode("utf-8")).hexdigest()