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>
187 lines
6.9 KiB
Python
187 lines
6.9 KiB
Python
"""LibraryService — read-only Library and Stats over analysis results.
|
|
|
|
Ports webapp/query.py (search, facets, stats, top-tag/people/year aggregates) onto
|
|
the shared database, re-keyed to ``asset_id`` and joined to ``assets`` for the
|
|
current path. The donor read a path-keyed ``photos`` table with an FTS5 index; here
|
|
search is a tokenised ``LIKE`` over description + tags.
|
|
ponytail: restore FTS5 (rank-ordered relevance) if search recall/latency matters at
|
|
real library size — LIKE is fine for browsing tens of thousands of rows.
|
|
|
|
Extracted from webapp/query.py (donor_ledger.yaml: wa-query-search, wa-query-stats).
|
|
Read-only: writes belong to AnalysisService.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
|
|
from sqlalchemy import and_, func, or_, select
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from photo_pipeline.models import AnalysisResult, Asset
|
|
|
|
CARD_FIELDS = (
|
|
"status",
|
|
"description",
|
|
"people_count",
|
|
"setting",
|
|
"time_of_day",
|
|
"season",
|
|
"mood",
|
|
"location_hint",
|
|
"approx_year",
|
|
)
|
|
DONE = ("analyzed",)
|
|
_SORTS = {
|
|
"year": (AnalysisResult.approx_year.desc(), Asset.current_path),
|
|
"people": (AnalysisResult.people_count.desc(), Asset.current_path),
|
|
"recent": (AnalysisResult.analyzed_at.desc(), Asset.current_path),
|
|
"path": (Asset.current_path,),
|
|
}
|
|
|
|
|
|
def _album_of(path: str | None) -> str:
|
|
return Path(path).parent.name if path else "(unknown)"
|
|
|
|
|
|
class LibraryService:
|
|
def __init__(self, session_factory: sessionmaker) -> None:
|
|
self._session_factory = session_factory
|
|
|
|
def search(self, q="", filters=None, sort="path", offset=0, limit=60) -> dict:
|
|
filters = filters or {}
|
|
with self._session_factory() as session:
|
|
stmt = select(AnalysisResult, Asset.current_path).join(
|
|
Asset, Asset.id == AnalysisResult.asset_id
|
|
)
|
|
conds = _filter_conditions(filters)
|
|
for token in re.findall(r"\w+", q, re.UNICODE):
|
|
like = f"%{token}%"
|
|
conds.append(
|
|
or_(AnalysisResult.description.ilike(like), AnalysisResult.tags.ilike(like))
|
|
)
|
|
if conds:
|
|
stmt = stmt.where(and_(*conds))
|
|
total = session.scalar(select(func.count()).select_from(stmt.subquery()))
|
|
stmt = stmt.order_by(*_SORTS.get(sort, _SORTS["path"])).limit(limit).offset(offset)
|
|
rows = [_card(result, path) for result, path in session.execute(stmt)]
|
|
return {"rows": rows, "total": total, "offset": offset, "limit": limit}
|
|
|
|
def stats(self) -> dict:
|
|
with self._session_factory() as session:
|
|
status = dict(
|
|
session.execute(
|
|
select(AnalysisResult.status, func.count()).group_by(AnalysisResult.status)
|
|
).all()
|
|
)
|
|
rows = list(
|
|
session.execute(
|
|
select(AnalysisResult, Asset.current_path).join(
|
|
Asset, Asset.id == AnalysisResult.asset_id
|
|
)
|
|
)
|
|
)
|
|
albums: dict[str, dict] = {}
|
|
tag_counts: Counter = Counter()
|
|
year_counts: Counter = Counter()
|
|
people: Counter = Counter()
|
|
errors = []
|
|
for result, path in rows:
|
|
album = _album_of(path)
|
|
bucket = albums.setdefault(album, {"album": album, "done": 0, "total": 0})
|
|
bucket["total"] += 1
|
|
if result.status in DONE:
|
|
bucket["done"] += 1
|
|
for tag in _tags(result.tags):
|
|
tag_counts[tag] += 1
|
|
if result.approx_year is not None:
|
|
year_counts[result.approx_year] += 1
|
|
if result.people_count is not None:
|
|
people["3+" if result.people_count >= 3 else str(result.people_count)] += 1
|
|
if result.status == "error":
|
|
errors.append({"path": path, "error": result.error_message})
|
|
return {
|
|
"total": sum(status.values()),
|
|
"status": status,
|
|
"setting": self._facet("setting"),
|
|
"time_of_day": self._facet("time_of_day"),
|
|
"season": self._facet("season"),
|
|
"people": [{"value": v, "count": n} for v, n in sorted(people.items())],
|
|
"years": [{"value": y, "count": year_counts[y]} for y in sorted(year_counts)],
|
|
"top_tags": [{"value": t, "count": n} for t, n in tag_counts.most_common(40)],
|
|
"albums": sorted(albums.values(), key=lambda d: d["album"]),
|
|
"errors": sorted(errors, key=lambda e: e["path"] or ""),
|
|
}
|
|
|
|
def facets(self) -> dict:
|
|
return {
|
|
"setting": self._facet("setting"),
|
|
"time_of_day": self._facet("time_of_day"),
|
|
"season": self._facet("season"),
|
|
"status": self._facet("status"),
|
|
}
|
|
|
|
def _facet(self, field: str) -> list[dict]:
|
|
column = getattr(AnalysisResult, field)
|
|
with self._session_factory() as session:
|
|
rows = session.execute(
|
|
select(column, func.count())
|
|
.where(column.is_not(None), func.trim(column) != "")
|
|
.group_by(column)
|
|
.order_by(func.count().desc())
|
|
).all()
|
|
return [{"value": value, "count": count} for value, count in rows]
|
|
|
|
|
|
def _filter_conditions(filters: dict) -> list:
|
|
conds = []
|
|
for key, column in (
|
|
("setting", AnalysisResult.setting),
|
|
("tod", AnalysisResult.time_of_day),
|
|
("season", AnalysisResult.season),
|
|
("status", AnalysisResult.status),
|
|
):
|
|
if filters.get(key):
|
|
conds.append(column == filters[key])
|
|
people = filters.get("people")
|
|
if people == "3+":
|
|
conds.append(AnalysisResult.people_count >= 3)
|
|
elif people in ("0", "1", "2"):
|
|
conds.append(AnalysisResult.people_count == int(people))
|
|
if filters.get("year_min"):
|
|
conds.append(AnalysisResult.approx_year >= int(filters["year_min"]))
|
|
if filters.get("year_max"):
|
|
conds.append(AnalysisResult.approx_year <= int(filters["year_max"]))
|
|
if filters.get("has_location"):
|
|
conds.append(
|
|
and_(
|
|
AnalysisResult.location_hint.is_not(None),
|
|
func.trim(AnalysisResult.location_hint) != "",
|
|
func.lower(AnalysisResult.location_hint) != "null",
|
|
)
|
|
)
|
|
return conds
|
|
|
|
|
|
def _card(result: AnalysisResult, path: str | None) -> dict:
|
|
card = {field: getattr(result, field) for field in CARD_FIELDS}
|
|
card.update(
|
|
asset_id=result.asset_id,
|
|
current_path=path,
|
|
album=_album_of(path),
|
|
tags=_tags(result.tags),
|
|
)
|
|
return card
|
|
|
|
|
|
def _tags(raw: str | None) -> list[str]:
|
|
if not raw:
|
|
return []
|
|
try:
|
|
return json.loads(raw)
|
|
except (ValueError, TypeError):
|
|
return []
|