diff --git a/apps/trip-cluster/app/review.py b/apps/trip-cluster/app/review.py new file mode 100644 index 0000000..2c2e76b --- /dev/null +++ b/apps/trip-cluster/app/review.py @@ -0,0 +1,41 @@ +import datetime + + +def _now() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat() + + +def approve(store, cluster_id, name=None) -> None: + c = store.get_cluster(cluster_id) + decided = (name or "").strip() or c.suggested_name + store.update_cluster(cluster_id, status="approved", + decided_name=decided, reviewed_at=_now()) + + +def mark_non_trip(store, cluster_id) -> None: + store.update_cluster(cluster_id, status="non_trip", reviewed_at=_now()) + + +def skip(store, cluster_id) -> None: + store.update_cluster(cluster_id, status="skipped", reviewed_at=_now()) + + +def split(store, cluster_id, boundary_immich_id): + return store.split_cluster(cluster_id, boundary_immich_id) + + +def merge(store, cluster_id_a, cluster_id_b): + return store.merge_clusters(cluster_id_a, cluster_id_b) + + +def set_member(store, cluster_id, immich_id, included: bool) -> None: + store.set_member_inclusion(cluster_id, immich_id, included) + + +def approve_high_confidence(store, threshold: float = 0.75) -> int: + count = 0 + for c in store.all_clusters(): + if c.status == "pending" and c.kind_guess == "trip" and c.confidence >= threshold: + approve(store, c.id) + count += 1 + return count diff --git a/apps/trip-cluster/tests/test_review.py b/apps/trip-cluster/tests/test_review.py new file mode 100644 index 0000000..8c25208 --- /dev/null +++ b/apps/trip-cluster/tests/test_review.py @@ -0,0 +1,59 @@ +from photoflow.core import Store +from photoflow.core.models import Asset, Cluster, ClusterMember +from app import review + + +def _store(tmp_path): + s = Store(str(tmp_path / "t.db")).connect() + for i, t in [("a", "2019-06-01"), ("b", "2019-06-02"), ("c", "2019-06-03")]: + s.upsert_asset(Asset(immich_id=i, taken_at=t)) + return s + + +def _cluster(s, name="Trip", conf=0.9, kind="trip", ids=("a", "b")): + return s.insert_cluster( + Cluster(start_at="2019-06-01", end_at="2019-06-03", suggested_name=name, + confidence=conf, kind_guess=kind, status="pending"), + [ClusterMember(cluster_id=0, immich_id=i) for i in ids]) + + +def test_approve_uses_suggested_when_no_name(tmp_path): + s = _store(tmp_path) + cid = _cluster(s, name="Venice") + review.approve(s, cid) + c = s.get_cluster(cid) + assert c.status == "approved" and c.decided_name == "Venice" and c.reviewed_at + review.approve(s, cid, name="Venezia") + assert s.get_cluster(cid).decided_name == "Venezia" + s.close() + + +def test_non_trip_skip(tmp_path): + s = _store(tmp_path) + cid = _cluster(s) + review.mark_non_trip(s, cid) + assert s.get_cluster(cid).status == "non_trip" + c2 = _cluster(s) + review.skip(s, c2) + assert s.get_cluster(c2).status == "skipped" + s.close() + + +def test_set_member(tmp_path): + s = _store(tmp_path) + cid = _cluster(s, ids=("a", "b")) + review.set_member(s, cid, "b", False) + inc = {m.immich_id: m.included for _, m in s.cluster_members(cid)} + assert inc["b"] is False + s.close() + + +def test_approve_high_confidence_only_trips(tmp_path): + s = _store(tmp_path) + hi = _cluster(s, conf=0.9, kind="trip") + _cluster(s, conf=0.3, kind="trip") # too low + _cluster(s, conf=0.9, kind="everyday") # everyday excluded + n = review.approve_high_confidence(s, threshold=0.75) + assert n == 1 + assert s.get_cluster(hi).status == "approved" + s.close()