Compare commits

...
33 Commits
Author SHA1 Message Date
m038 cc8de0e52d docs: add BACKLOG.md for near-term open tasks
Track loose ends between milestones: the pgvector spike's final coverage
snapshot (pending CLIP re-run completion), the M1 validation-gate run on a hard
sample, and the M1.5 least-privilege read-only role. Linked from ROADMAP.
2026-06-27 22:34:55 +02:00
m038 d9ba194b30 docs(spike): add runbook + definition of done
Consolidate the scattered run instructions and pass/fail criteria into one place:
- design spec gains 'Running the spike' (venv bootstrap + flags + DB prereqs) and
  'Definition of done' (Req 0-4 are the bar; coverage does not gate done-ness;
  the only follow-up at ~100% CLIP coverage is one no-flag re-run to snapshot the
  final coverage into the contract doc) sections, and the status line now reads
  'passed 2026-06-27'.
- script docstring Usage now includes the missing 'python3 -m venv .venv' bootstrap
  a fresh checkout needs, and points to the spec's definition of done.
2026-06-27 22:31:26 +02:00
m038 1a6afe86fc fix(review): operator-agnostic sanity check + invariant TODOs + dedupe
Code-review follow-ups on the pgvector spike:
- self-similarity sanity check now compares to the seed's own self-distance
  (0 for cosine/L2, ~-1 for inner product <#>) instead of a hardcoded ~0, so it
  no longer misfires if Immich ever uses a vector_ip_ops index (correctness P3).
- mark the deliberate raw-SQLite read in the probe as a spike-only exception and
  add an M1.5 TODO that the real pgvector reader belongs in shared/photoflow/immich
  and SQLite access in shared/photoflow/core (project-standards P2 x2).
- document join_and_coverage's return shape; extract a _pct() helper to dedupe the
  coverage-percentage formatting (maintainability P3 x2).
Findings doc refreshed from the latest live run (coverage now ~46%, re-run ongoing).
2026-06-27 20:50:06 +02:00
m038 7745f05323 docs(roadmap): correct M1.5 spike claims (spike now run + verified)
Fixes both false claims the spec flagged: M1.5 was never 'verified in M1' and M1
did not 'run a read-only feasibility spike' (it deferred it). The spike has now
run separately and passed, so both are updated to point at the findings spec
(smart_search.embedding, 1152-dim, cosine, clean assetId->asset.id join) and the
spike is listed under Related specs.
2026-06-27 20:40:26 +02:00
m038 4882f4ca5a feat(spike): read-only pgvector probe + live findings
scripts/pgvector_spike.py probes Immich's Postgres read-only (session-level
read-only guard) and answers the M1.5 prerequisites against the live DB:
catalog-discovered embedding table/column, vector dimension, distance operator
(from the index opclass), the embedding->asset FK join, and coverage over the
IMAGE population. Discovers the asset table name from the FK (asset, not the
legacy assets) rather than hardcoding it, so it survives Immich version drift.

Findings (this Immich version): smart_search.embedding, 1152-dim, cosine <=>
(vector_cosine_ops), assetId->asset.id join clean (0 orphans), ~44% image
coverage (CLIP re-run in progress). Doc is M1.5's version-pinned contract.
2026-06-27 20:39:31 +02:00
m038 ade19fa094 feat(config): add optional IMMICH_DB_URL for pgvector spike
Optional Postgres DSN field on Config (REST creds stay required), env.example
entry, and a throwaway scripts/requirements-spike.txt (psycopg3 + pgvector)
kept out of the app's runtime deps. Unblocks the M1.5 pgvector feasibility spike.
2026-06-27 20:32:07 +02:00
m038andClaude Opus 4.8 33ac3ae69c docs(spec): harden pgvector spike from ce-doc-review (11 findings)
Multi-persona review (coherence, feasibility, product-lens, security-lens,
adversarial) surfaced that the spike proved DB access but not that DB access
was the right path or that the signal was useful, and pinned a contract
against a private, in-flight-changing schema. Applied 11 fixes:

- Requirement 0: test the "REST can't expose embeddings" premise instead of
  asserting it; record which endpoints were checked and why insufficient.
- Reframe Req 1 "real go/no-go" to access-only; signal-usefulness is M1.5's
  first task, not this spike's.
- Mark the schema unsupported/internal, version-pinned; require an M1.5
  re-probe/version-guard per Immich upgrade; tie shape to recorded model+ver.
- Fix probe correctness: pgvector adapter / server-side vector_dims (psycopg3
  returns vector as string); Postgres-internal join with optional SQLite
  cross-check; coverage over the image/embeddable population (both ratios).
- DB-enforced read-only session; standalone .env loading; psycopg+pgvector
  added to Deliverables; roadmap correction now fixes both false claims.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 20:19:09 +02:00
m038andClaude Opus 4.8 e5923317de docs(spec): pgvector embedding feasibility spike (M1.5 dependency)
Defines the read-only Postgres/pgvector feasibility spike that the
roadmap claims ran in M1 but the M1 plan deferred. Pins the four
pass/fail requirements, the disposable-probe approach, and the
findings-doc contract that unblocks M1.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:41:59 +02:00
m038 fccc74f8b8 docs(solutions): document Gitea PR-creation workflow (gh/tea limits -> REST API) 2026-06-27 19:33:56 +02:00
m038 fa9a5e14c2 docs: add M1 implementation plan 2026-06-27 19:25:15 +02:00
m038 389c5fbfee docs: align M1 design spec with ROADMAP (M1.5, shared/ai -> M3) 2026-06-27 19:25:15 +02:00
m038 3968ccb704 docs(roadmap): mark M1 shipped; add M1.5 visual-similarity milestone 2026-06-27 19:24:04 +02:00
m038 19394908d2 docs: add Gitea env vars to .env.example 2026-06-27 19:19:25 +02:00
m038 d5b073da9c Merge pull request 'feat: M1 foundation packages + trip-cluster app' (#1) from feat/m1-foundation-trip-cluster into master
Reviewed-on: #1
2026-06-27 19:10:48 +02:00
m038 bd8fe59b49 fix(ingest): don't advance incremental cursor past a failed/sampled asset
A later successful asset could push last_ingest_at past an earlier asset whose
thumbnail download failed, permanently excluding it from later incremental runs
(only --full recovered it). Now the cursor never advances to/past the earliest
failed asset, never below the prior cursor, and not at all on a --subset run.
Regression tests added. (review finding #9)
2026-06-27 18:49:24 +02:00
m038 ae093881fa fix(review): harden write-back, confirmation gate, and edge cases
- write-back: call upsert_tag inside _apply_tag's try so a tag-create failure
  is caught per-asset and apply_all no longer aborts mid-batch (was P1)
- write-back: surface _pipeline/processed write failures in the result instead
  of discarding them (was reported as success)
- ui: add title to the high confidence badge so approve-high-confidence's
  pre-action count is non-zero; confirm() before single-cluster apply
- core: Store context manager; close DB connection even if a route raises;
  guard split_cluster against a first-member boundary (empty cluster); add
  writeback_log lookup index
- ingest: split thumbnail download/write error handling and clean up the
  .tmp file on a write failure
- tests: upsert/processed write-failure regression tests + split-guard test
2026-06-27 17:54:38 +02:00
m038 d67b677b6a docs+docker: README, CLAUDE.md, Dockerfile/compose (8084), M1 validation gate 2026-06-27 17:37:22 +02:00
m038 c4ff5bcb06 test(trip-cluster): Playwright UI — grid nav, lightbox, cluster switching, approve 2026-06-27 17:35:58 +02:00
m038 edf0735098 feat(trip-cluster): master/detail review routes + templates + clusterReview JS 2026-06-27 17:35:58 +02:00
m038 44c05444d3 feat(trip-cluster): idempotent write-back + CLI apply with confirmation 2026-06-27 17:35:58 +02:00
m038 5256df4117 feat(trip-cluster): cluster orchestration (seed derivation + coverage) + CLI 2026-06-27 17:28:27 +02:00
m038 a9b443cd53 feat(trip-cluster): cluster review operations (approve/non-trip/skip/split/merge/bulk) 2026-06-27 17:21:38 +02:00
m038 6d0c9662ee feat(trip-cluster): ingest — scopeable, incremental, processed read-back 2026-06-27 17:21:38 +02:00
m038 8d58ec03a9 feat(trip-cluster): coverage detection — completeness flags + outliers 2026-06-27 17:19:06 +02:00
m038 e01b30a5b5 feat(trip-cluster): config, factory, CLI skeleton, health + thumb proxy 2026-06-27 17:19:06 +02:00
m038 9ec6512428 feat(core): cluster data-access — attention sort, neighbors, split/merge, writeback log 2026-06-27 17:19:06 +02:00
m038 76be1fb5af feat(trip-cluster): density-adaptive timestamp clustering + tag seeds + anchors 2026-06-27 17:16:07 +02:00
m038 351a001903 feat(core): asset & tag data-access 2026-06-27 17:16:07 +02:00
m038 da5b1de2a2 feat(immich): write-back (upsert_tag, tag_assets) + _pipeline tag conventions 2026-06-27 17:16:07 +02:00
m038 c36d93e3c7 feat(ui): base.html, macros (badges, lightbox), shared.js, register_shared_ui 2026-06-27 17:11:49 +02:00
m038 4db684fbdc feat(core): domain models + Store (schema, connection, meta) 2026-06-27 17:11:49 +02:00
m038 862480916a feat(immich): ImmichClient read — search_assets, list_tags, resolve_tag_id, download_thumbnail 2026-06-27 17:11:49 +02:00
m038 01ac95059e feat: monorepo scaffold — photoflow shared dist + trip-cluster app skeleton 2026-06-27 17:08:11 +02:00
65 changed files with 7765 additions and 14 deletions
+14
View File
@@ -0,0 +1,14 @@
IMMICH_URL=http://your-immich-host:2283
IMMICH_API_KEY=your-immich-api-key
# Optional: Postgres DSN for read-only access to Immich's pgvector embeddings.
# Only needed by the pgvector spike (scripts/pgvector_spike.py) / M1.5 visual similarity.
# Format: postgresql://USER:PASSWORD@HOST:PORT/DBNAME (Immich defaults: user=postgres, db=immich)
# IMMICH_DB_URL=postgresql://postgres:your-db-password@your-immich-host:5432/immich
ANTHROPIC_API_KEY=
DATA_DIR=./data
UID=1000
GID=1000
GITEA_HOST=
GITEA_USER=
GITEA_TOKEN=
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.pyc
.venv/
data/
*.db
.env
.pytest_cache/
*.egg-info/
+38
View File
@@ -0,0 +1,38 @@
# CLAUDE.md
## What this is
A monorepo (M1) of an Immich photo-flow foundation + the `trip-cluster` app. See
`docs/ROADMAP.md` for milestones and `docs/superpowers/specs/2026-06-27-immich-photo-flow-design.md`
for the M1 design.
## Architecture map
- `shared/photoflow/immich`**the only** Immich client (`client.py`) + `_pipeline/` tag
conventions (`pipeline.py`).
- `shared/photoflow/core`**the only** SQLite owner: `store.py` (data-access) + `models.py`.
- `shared/photoflow/ui``base.html`, shared Jinja macros, `shared.js` grid+lightbox.
- `apps/trip-cluster/app``config.py`, `cli.py` (ingest|cluster|serve|apply),
`ingest.py`, `clustering.py` (pure), `coverage.py` (pure), `cluster_run.py`,
`review.py`, `writeback.py`, `routes/`, `templates/`, `static/app.js`.
## Key invariants
- **Immich is the source of truth**; SQLite is rebuildable. Only **applied** decisions
survive loss of SQLite (via `_pipeline/processed` + `writeback_log`).
- Content/trip tags are **never namespaced**; pipeline meta-tags nest under `_pipeline/`.
- Trip detection is **timestamp-first, density-adaptive**, anchored by existing trip tags,
refined by GPS. Existing trip tags are authoritative seeds.
- Write-back is **idempotent** (`writeback_log`) and needs **explicit confirmation**.
- Ingest is **scopeable** (`--from/--to`, `--tag`, `--subset`) and incremental (`updatedAfter`).
- trip-cluster serves on **8084**.
## Dev commands
```bash
.venv/bin/python -m pytest # unit + route (Immich mocked)
.venv/bin/python -m pytest apps/trip-cluster/tests/ui # Playwright
docker compose up # UI on :8084
```
Write a failing test first (TDD). Mirrors the sibling apps in `/home/mischa/Projects/`.
+37
View File
@@ -0,0 +1,37 @@
# immich-photo-flow
A monorepo of small tools that clean up and structure a large [Immich](https://immich.app)
library into travel "memories". **M1** ships the shared foundation
(`shared/photoflow/{immich,core,ui}`) and the `trip-cluster` app.
## Layout
- `shared/photoflow/immich` — the one Immich REST client + `_pipeline/` tag conventions.
- `shared/photoflow/core` — SQLite store + domain models (the only SQL).
- `shared/photoflow/ui` — base template, DaisyUI/Tailwind/Alpine/HTMX, shared grid+lightbox.
- `apps/trip-cluster` — CLI + Flask review UI (port 8084).
## Dev setup
```bash
python3.12 -m venv .venv
.venv/bin/pip install -e ./shared -e ./apps/trip-cluster \
pytest==8.3.4 pytest-httpserver==1.1.0 pytest-playwright==0.6.2
.venv/bin/python -m pytest # unit + route tests
.venv/bin/python -m playwright install chromium
.venv/bin/python -m pytest apps/trip-cluster/tests/ui # Playwright UI
```
## trip-cluster workflow
```bash
cp .env.example .env # fill IMMICH_URL + IMMICH_API_KEY
cd apps/trip-cluster
python categorize.py ingest --tag "Italy 2019" # or --from/--to/--subset
python categorize.py cluster
python categorize.py serve # review at http://localhost:8084
python categorize.py apply # write approved tags back (asks to confirm)
```
Immich is the source of truth; SQLite is a rebuildable working layer. Content/trip tags
are never namespaced; pipeline meta-tags live under `_pipeline/`.
+9
View File
@@ -0,0 +1,9 @@
FROM python:3.12-slim
WORKDIR /srv
COPY shared/ ./shared/
COPY apps/trip-cluster/ ./apps/trip-cluster/
RUN pip install --no-cache-dir ./shared ./apps/trip-cluster
ENV DATA_DIR=/data
EXPOSE 8084
WORKDIR /srv/apps/trip-cluster
CMD ["python", "categorize.py", "serve"]
+21
View File
@@ -0,0 +1,21 @@
from flask import Flask
from app.config import load_config
from photoflow.ui import register_shared_ui
def create_app(config=None) -> Flask:
app = Flask(__name__)
cfg = config or load_config()
app.config["APP_CONFIG"] = cfg
app.config["DATA_DIR"] = cfg.data_dir
register_shared_ui(app)
from app.routes.nav import bp as nav_bp
from app.routes.review import bp as review_bp
from app.routes.proxy import bp as proxy_bp
app.register_blueprint(nav_bp)
app.register_blueprint(review_bp)
app.register_blueprint(proxy_bp)
return app
+113
View File
@@ -0,0 +1,113 @@
import argparse
import sys
from app.config import load_config
def _store(cfg):
import os
from photoflow.core import Store
os.makedirs(cfg.data_dir, exist_ok=True)
return Store(cfg.db_path).connect()
def _immich(deps, cfg):
if "immich" in deps:
return deps["immich"]
from photoflow.immich import ImmichClient
return ImmichClient(cfg.immich_url, cfg.immich_api_key)
def cmd_ingest(deps, *, date_from, date_to, tag, subset, full) -> int:
cfg = deps["config"]
store = _store(cfg)
client = _immich(deps, cfg)
from app.ingest import run_ingest
res = run_ingest(client, store, cfg.thumbs_dir, date_from=date_from,
date_to=date_to, tag=tag, subset=subset, full=full)
store.close()
print(f"Ingested {res['fetched']} asset(s); "
f"{res['processed_marked']} already-processed.")
return 0
def cmd_cluster(deps, *, gap_factor) -> int:
cfg = deps["config"]
store = _store(cfg)
from app.cluster_run import run_cluster
res = run_cluster(store, gap_factor=gap_factor)
store.close()
print(f"Built {res['clusters']} candidate cluster(s). Run 'categorize serve' to review.")
return 0
def cmd_apply(deps, *, yes) -> int:
cfg = deps["config"]
store = _store(cfg)
from app.writeback import apply_all, APPLYABLE
pending = [c for c in store.all_clusters() if c.status in APPLYABLE]
if not pending:
print("Nothing to apply.")
store.close()
return 0
if not yes:
ans = input(f"Apply {len(pending)} cluster decision(s) to Immich? [y/N] ")
if ans.strip().lower() not in ("y", "yes"):
print("Aborted.")
store.close()
return 1
client = _immich(deps, cfg)
results = apply_all(client, store)
store.close()
ok = sum(len(r["succeeded"]) for r in results)
bad = sum(len(r["failed"]) for r in results)
print(f"Applied {ok} tag write(s); {bad} failure(s) across {len(results)} cluster(s).")
return 0 if bad == 0 else 1
def cmd_serve(deps) -> int:
from app import create_app
create_app(deps["config"]).run(host="0.0.0.0", port=8084)
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="categorize")
sub = p.add_subparsers(dest="command", required=True)
ing = sub.add_parser("ingest")
ing.add_argument("--from", dest="date_from")
ing.add_argument("--to", dest="date_to")
ing.add_argument("--tag")
ing.add_argument("--subset", type=int)
ing.add_argument("--full", action="store_true", help="ignore incremental updatedAfter")
cl = sub.add_parser("cluster")
cl.add_argument("--gap-factor", type=float, default=6.0)
sub.add_parser("serve")
ap = sub.add_parser("apply")
ap.add_argument("--yes", action="store_true", help="skip the confirmation prompt")
return p
def main(argv=None) -> int:
args = build_parser().parse_args(argv)
deps = {"config": load_config()}
if args.command == "serve":
return cmd_serve(deps)
if args.command == "ingest":
return cmd_ingest(deps, date_from=args.date_from, date_to=args.date_to,
tag=args.tag, subset=args.subset, full=args.full)
if args.command == "cluster":
return cmd_cluster(deps, gap_factor=args.gap_factor)
if args.command == "apply":
return cmd_apply(deps, yes=args.yes)
print(f"Command '{args.command}' is not implemented yet.")
return 1
if __name__ == "__main__":
sys.exit(main())
+57
View File
@@ -0,0 +1,57 @@
from collections import defaultdict
from photoflow.core.models import Cluster
from photoflow.immich import pipeline
from app.clustering import cluster_assets, _epoch
from app.coverage import coverage_members
# A tag seeds only if its photos dominate the time window they span: among all
# assets whose taken_at falls in [min, max] of the tag's photos, at least this
# fraction must carry the tag. A name/event tag sprinkled among many untagged
# photos in the same window fails this gate.
SEED_DOMINANCE_MIN = 0.5
def _seed_tags(assets, tags_by_asset, max_span_days: int,
dominance_min: float = SEED_DOMINANCE_MIN) -> set:
epochs = {a.immich_id: _epoch(a.taken_at) for a in assets}
tagged = defaultdict(list) # tag -> epochs of its assets
for a in assets:
for t in tags_by_asset.get(a.immich_id, []):
if not pipeline.is_pipeline_tag(t):
tagged[t].append(epochs[a.immich_id])
seeds = set()
for tag, ts in tagged.items():
if len(ts) < 2:
continue
lo, hi = min(ts), max(ts)
# Loose safety cap only — reject absurdly long spans, but keep genuine
# long trips (this library has one > 60 days), so the default is high.
if hi - lo > max_span_days * 86400:
continue
# Dominance / temporal contiguity gate.
in_window = sum(1 for e in epochs.values() if lo <= e <= hi)
if in_window and len(ts) / in_window >= dominance_min:
seeds.add(tag)
return seeds
def run_cluster(store, *, gap_factor: float = 6.0, seed_max_span_days: int = 400) -> dict:
assets = store.all_assets(include_processed=False)
tags_by_asset = {a.immich_id: store.asset_tags(a.immich_id) for a in assets}
seed_tags = _seed_tags(assets, tags_by_asset, seed_max_span_days)
candidates = cluster_assets(assets, tags_by_asset, seed_tags, gap_factor=gap_factor)
assets_by_id = {a.immich_id: a for a in assets}
store.clear_clusters()
n = 0
for cand in candidates:
members = coverage_members(cand, assets_by_id)
store.insert_cluster(Cluster(
start_at=cand.start_at, end_at=cand.end_at,
count=sum(1 for m in members if m.included),
suggested_name=cand.suggested_name, confidence=cand.confidence,
kind_guess=cand.kind_guess, status="pending"), members)
n += 1
return {"clusters": n}
+192
View File
@@ -0,0 +1,192 @@
import datetime
from collections import Counter
from dataclasses import dataclass
from typing import Optional
EVERYDAY_MAX_COUNT = 4
SEED_CONFIDENCE = 0.95
# Public aliases for cross-module use are defined at the bottom of this file
# (epoch / median) so a later coverage.py can consume them without reaching
# for the underscore-prefixed names. The underscore names are kept too, since
# Task 11 (coverage.py) imports `_epoch` / `_median` directly.
__all__ = [
"CandidateCluster",
"cluster_assets",
"_epoch",
"_median",
"epoch",
"median",
]
@dataclass
class CandidateCluster:
member_ids: list
start_at: str
end_at: str
suggested_name: str
confidence: float
kind_guess: str
seed_tag: Optional[str] = None
def _epoch(taken_at: str) -> float:
s = (taken_at or "").strip()
if not s:
return 0.0
s = s.replace("Z", "")
if "." in s:
s = s.split(".", 1)[0]
try:
if "T" in s:
return datetime.datetime.fromisoformat(s).timestamp()
return datetime.datetime.fromisoformat(s + "T00:00:00").timestamp()
except ValueError:
return 0.0
def _median(values: list) -> float:
if not values:
return 0.0
xs = sorted(values)
n = len(xs)
mid = n // 2
return xs[mid] if n % 2 else (xs[mid - 1] + xs[mid]) / 2
def _span(members: list) -> tuple:
ts = [m.taken_at for m in members]
return (min(ts), max(ts)) if ts else ("", "")
def _name(members: list, start_at: str) -> str:
cities = Counter(m.place_city for m in members if m.place_city)
if cities:
return cities.most_common(1)[0][0]
countries = Counter(m.place_country for m in members if m.place_country)
if countries:
return countries.most_common(1)[0][0]
return f"Trip {start_at[:10]}"
def _tightness(members: list) -> float:
"""Temporal-tightness score in [0, 1]: how regular the intra-cluster time
gaps are (low gap variance -> high score).
A densely/regularly shot cluster (e.g. a steady stream of photos through a
day) is strong evidence of a coherent event even when GPS is absent. We
measure regularity via the coefficient of variation (stdev / mean) of the
consecutive gaps and reward a low value. This lets a GPS-poor but tightly
packed cluster clear the downstream 0.75 bulk-approve gate, which the
pure GPS+size score could never reach (it caps at 0.50 when gps_frac == 0).
"""
ts = sorted(_epoch(m.taken_at) for m in members)
gaps = [b - a for a, b in zip(ts, ts[1:])]
if not gaps:
return 0.0 # single member: no temporal signal
if len(gaps) < 2:
return 1.0 # one gap: trivially regular
mean = sum(gaps) / len(gaps)
if mean <= 0:
return 1.0 # all timestamps coincide: maximally tight
var = sum((g - mean) ** 2 for g in gaps) / len(gaps)
cv = (var ** 0.5) / mean
return max(0.0, 1.0 - cv)
def _confidence(members: list) -> float:
"""Heuristic confidence in [0, 0.85] that a free cluster is a real event.
Term rationale:
- 0.30 base: even a bare timestamp cluster is a weak positive signal, so
we never start from zero.
- 0.40 * gps_frac: geotagging is the strongest single signal that photos
belong to one outing, hence the largest weight.
- 0.20 * size_frac: more photos (saturating at 20) make a stray-photo
false positive less likely.
- 0.35 * tightness: regular/dense timing is independent evidence of a
coherent event; weighted so a fully GPS-poor cluster can still reach
the 0.85 cap (0.30 + 0.20 + 0.35) and clear the 0.75 approve gate.
The 0.85 cap reserves >0.90 confidence exclusively for tag-seeded clusters.
"""
count = len(members)
gps_frac = sum(1 for m in members if m.gps_lat is not None) / count if count else 0
size_frac = min(count / 20, 1)
conf = 0.30 + 0.40 * gps_frac + 0.20 * size_frac + 0.35 * _tightness(members)
return round(min(conf, 0.85), 2)
def _free_cluster(members: list) -> CandidateCluster:
start, end = _span(members)
return CandidateCluster(
member_ids=[m.immich_id for m in members],
start_at=start, end_at=end,
suggested_name=_name(members, start),
confidence=_confidence(members),
kind_guess="everyday" if len(members) <= EVERYDAY_MAX_COUNT else "trip")
def _gap_cluster(assets: list, *, gap_factor, hard_split_days, min_floor_seconds) -> list:
ordered = sorted(assets, key=lambda a: a.taken_at)
if not ordered:
return []
hard_cap = hard_split_days * 86400
groups = []
group = [ordered[0]]
group_gaps: list = []
for prev, cur in zip(ordered, ordered[1:]):
gap = _epoch(cur.taken_at) - _epoch(prev.taken_at)
if gap > hard_cap:
split = True
elif len(group_gaps) < 2: # bootstrap: accept first 2 gaps
split = False
else:
threshold = max(gap_factor * _median(group_gaps), min_floor_seconds)
split = gap > threshold
if split:
groups.append(group)
group = [cur]
group_gaps = []
else:
group.append(cur)
group_gaps.append(gap)
groups.append(group)
return [_free_cluster(g) for g in groups]
def cluster_assets(assets, tags_by_asset, seed_tags, *, gap_factor=6.0,
hard_split_days=14, min_floor_seconds=3600) -> list:
by_id = {a.immich_id: a for a in assets}
used = set()
clusters = []
# 1. Seed clusters from existing trip tags (authoritative; never gap-split).
for tag in sorted(seed_tags):
members = [by_id[aid] for aid in by_id
if aid not in used and tag in tags_by_asset.get(aid, [])]
if not members:
continue
members.sort(key=lambda a: a.taken_at)
used.update(m.immich_id for m in members)
start, end = _span(members)
clusters.append(CandidateCluster(
member_ids=[m.immich_id for m in members], start_at=start, end_at=end,
suggested_name=tag, confidence=SEED_CONFIDENCE, kind_guess="trip",
seed_tag=tag))
# 2. Gap-cluster the remaining (free) assets.
free = [a for a in assets if a.immich_id not in used]
clusters.extend(_gap_cluster(free, gap_factor=gap_factor,
hard_split_days=hard_split_days,
min_floor_seconds=min_floor_seconds))
clusters.sort(key=lambda c: c.start_at)
return clusters
# Public aliases (Review revision 2): expose the timestamp/median helpers for
# cross-module reuse (e.g. coverage.py) without forcing callers onto the
# underscore-prefixed names. The underscore names remain importable.
epoch = _epoch
median = _median
+49
View File
@@ -0,0 +1,49 @@
import os
from dataclasses import dataclass
from typing import Mapping, Optional
REQUIRED = ["IMMICH_URL", "IMMICH_API_KEY"]
class ConfigError(Exception):
def __init__(self, missing: list):
self.missing = missing
super().__init__(f"Missing required environment variables: {', '.join(missing)}")
@dataclass
class Config:
immich_url: str
immich_api_key: str
anthropic_api_key: str
data_dir: str
# Postgres DSN for read-only access to Immich's pgvector embeddings.
# Optional: only the pgvector spike / M1.5 visual-similarity work needs it;
# REST creds (immich_url/api_key) stay required.
# M1.5 TODO: the actual pgvector reader belongs in shared/photoflow/immich
# (the only Immich client, per CLAUDE.md) — this field only carries the DSN.
immich_db_url: Optional[str] = None
@property
def db_path(self) -> str:
return os.path.join(self.data_dir, "trip-cluster.db")
@property
def thumbs_dir(self) -> str:
return os.path.join(self.data_dir, "thumbs")
def load_config(env: Optional[Mapping] = None) -> Config:
env = env if env is not None else os.environ
missing = [k for k in REQUIRED if not (env.get(k) or "").strip()]
if missing:
raise ConfigError(missing)
data_dir = (env.get("DATA_DIR") or "").strip() or os.path.join(os.getcwd(), "data")
immich_db_url = (env.get("IMMICH_DB_URL") or "").strip() or None
return Config(
immich_url=env["IMMICH_URL"].strip().rstrip("/"),
immich_api_key=env["IMMICH_API_KEY"].strip(),
anthropic_api_key=(env.get("ANTHROPIC_API_KEY") or "").strip(),
data_dir=data_dir,
immich_db_url=immich_db_url,
)
+35
View File
@@ -0,0 +1,35 @@
from photoflow.core.models import ClusterMember
from app.clustering import _epoch, _median
def coverage_members(candidate, assets_by_id, *, outlier_factor: float = 8.0) -> list:
member_ids = [i for i in candidate.member_ids if i in assets_by_id]
members = sorted((assets_by_id[i] for i in member_ids), key=lambda a: a.taken_at)
epochs = [_epoch(a.taken_at) for a in members]
gaps = [b - a for a, b in zip(epochs, epochs[1:])]
base = _median(gaps) if gaps else 0.0
out = []
for idx, a in enumerate(members):
is_outlier = False
if candidate.seed_tag and base > 0:
left = epochs[idx] - epochs[idx - 1] if idx > 0 else 0
right = epochs[idx + 1] - epochs[idx] if idx < len(members) - 1 else 0
nearest = min([g for g in (left, right) if g > 0], default=0)
if nearest > outlier_factor * base:
is_outlier = True
out.append(ClusterMember(
cluster_id=0, immich_id=a.immich_id,
member_confidence=candidate.confidence, is_outlier=is_outlier,
included=True, flagged_coverage=False))
if candidate.seed_tag and candidate.start_at and candidate.end_at:
mset = set(member_ids)
for a in sorted(assets_by_id.values(), key=lambda x: x.taken_at):
if a.immich_id in mset:
continue
if candidate.start_at <= a.taken_at <= candidate.end_at:
out.append(ClusterMember(
cluster_id=0, immich_id=a.immich_id, member_confidence=0.0,
is_outlier=False, included=False, flagged_coverage=True))
return out
+102
View File
@@ -0,0 +1,102 @@
import datetime
import logging
import os
from photoflow.core.models import Asset
from photoflow.immich import pipeline
log = logging.getLogger(__name__)
def _now() -> str:
return datetime.datetime.now(datetime.timezone.utc).isoformat()
def run_ingest(client, store, thumbs_dir, *, date_from=None, date_to=None,
tag=None, subset=None, full=False) -> dict:
os.makedirs(thumbs_dir, exist_ok=True)
tag_ids = None
if tag:
tid = client.resolve_tag_id(tag)
if not tid:
raise ValueError(f"Tag {tag!r} not found in Immich")
tag_ids = [tid]
updated_after = None if full else store.get_meta("last_ingest_at")
assets = client.search_assets(taken_after=date_from, taken_before=date_to,
tag_ids=tag_ids, updated_after=updated_after)
if subset is not None:
assets = assets[:subset]
processed_marked = 0
tag_counts: dict = {}
successful_updated: list = [] # updated_at of assets ingested this run
failed_updated: list = [] # updated_at of assets we could NOT ingest
for a in assets:
# Revision (2): sanitize the Immich-provided id before using it as a
# path component, guarding against path traversal (mirror /thumb proxy).
safe_id = os.path.basename(a["id"])
thumb_path = os.path.join(thumbs_dir, f"{safe_id}.jpg")
# Revision (1): treat an existing 0-byte file as missing.
need_thumb = (not os.path.exists(thumb_path)
or os.path.getsize(thumb_path) == 0)
if need_thumb:
try:
data = client.download_thumbnail(a["id"])
except Exception:
log.exception("Thumbnail download failed for asset %s", a["id"])
failed_updated.append(a["updated_at"])
continue
# Write to a temp path and os.replace into place so a failure
# never leaves a 0-byte <id>.jpg behind; clean up the temp file
# if the write itself fails (e.g. disk full).
tmp_path = f"{thumb_path}.tmp"
try:
with open(tmp_path, "wb") as f:
f.write(data)
os.replace(tmp_path, thumb_path)
except Exception:
log.exception("Thumbnail write failed for asset %s", a["id"])
try:
os.unlink(tmp_path)
except OSError:
pass
failed_updated.append(a["updated_at"])
continue
store.upsert_asset(Asset(
immich_id=a["id"], taken_at=a["taken_at"], gps_lat=a["gps_lat"],
gps_lon=a["gps_lon"], place_city=a["place_city"],
place_country=a["place_country"], type=a["type"],
thumb_path=thumb_path, ingested_at=_now(), updated_at=a["updated_at"]))
store.set_asset_tags(a["id"], a["tags"])
for t in a["tags"]:
tag_counts[t] = tag_counts.get(t, 0) + 1
if pipeline.PROCESSED in a["tags"]:
store.mark_processed(a["id"])
processed_marked += 1
successful_updated.append(a["updated_at"])
for name, count in tag_counts.items():
store.upsert_tag(name, count=count)
# Advance the incremental cursor, but never past an asset we failed to ingest
# (a later success must not strand an earlier failure on the next run), never
# below the prior cursor, and not at all on a --subset sampling run.
if subset is None:
new_watermark = updated_after or ""
floor = min((u for u in failed_updated if u), default=None)
for u in successful_updated:
if not u or (floor is not None and u >= floor):
continue
if u > new_watermark:
new_watermark = u
if new_watermark:
store.set_meta("last_ingest_at", new_watermark)
return {"fetched": len(assets), "processed_marked": processed_marked}
+41
View File
@@ -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
+24
View File
@@ -0,0 +1,24 @@
from flask import Blueprint, current_app, render_template
from photoflow.core import Store
bp = Blueprint("nav", __name__)
def _store():
cfg = current_app.config["APP_CONFIG"]
return Store(cfg.db_path).connect()
@bp.route("/health")
def health():
return "ok"
@bp.route("/")
def index():
s = _store()
clusters = s.clusters_by_attention()
stats = {"assets": len(s.all_assets()), "clusters": len(clusters),
"pending": sum(1 for c in clusters if c.status == "pending")}
s.close()
return render_template("review.html", clusters=clusters, stats=stats)
+14
View File
@@ -0,0 +1,14 @@
import os
from flask import Blueprint, current_app, send_file, abort
bp = Blueprint("proxy", __name__)
@bp.route("/thumb/<asset_id>")
def thumb(asset_id):
cfg = current_app.config["APP_CONFIG"]
safe = os.path.basename(asset_id)
path = os.path.join(cfg.thumbs_dir, f"{safe}.jpg")
if not os.path.exists(path):
abort(404)
return send_file(path, mimetype="image/jpeg")
+127
View File
@@ -0,0 +1,127 @@
from flask import Blueprint, current_app, render_template, request, jsonify, abort
from photoflow.core import Store
from app import review
bp = Blueprint("review", __name__)
def _store():
cfg = current_app.config["APP_CONFIG"]
return Store(cfg.db_path).connect()
def _client():
factory = current_app.config.get("IMMICH_FACTORY")
if factory:
return factory()
from photoflow.immich import ImmichClient
cfg = current_app.config["APP_CONFIG"]
return ImmichClient(cfg.immich_url, cfg.immich_api_key)
def _body():
return request.get_json(silent=True) or {}
@bp.route("/cluster/<int:cid>")
def detail(cid):
s = _store()
c = s.get_cluster(cid)
if c is None:
s.close()
abort(404)
members = s.cluster_members(cid)
prev_id, next_id = s.chronological_neighbors(cid)
s.close()
return render_template("_detail.html", c=c, members=members,
prev_id=prev_id, next_id=next_id)
@bp.route("/cluster/<int:cid>/approve", methods=["POST"])
def approve(cid):
s = _store()
review.approve(s, cid, _body().get("name"))
c = s.get_cluster(cid)
s.close()
return jsonify({"ok": True, "status": c.status, "name": c.decided_name})
@bp.route("/cluster/<int:cid>/non-trip", methods=["POST"])
def non_trip(cid):
s = _store()
review.mark_non_trip(s, cid)
s.close()
return jsonify({"ok": True, "status": "non_trip"})
@bp.route("/cluster/<int:cid>/skip", methods=["POST"])
def skip(cid):
s = _store()
review.skip(s, cid)
s.close()
return jsonify({"ok": True, "status": "skipped"})
@bp.route("/cluster/<int:cid>/split", methods=["POST"])
def split(cid):
s = _store()
boundary = _body().get("boundary_asset_id")
if not boundary:
s.close()
return jsonify({"error": "boundary_asset_id required"}), 400
try:
id1, id2 = review.split(s, cid, boundary)
except ValueError as e:
s.close()
return jsonify({"error": str(e)}), 400
s.close()
return jsonify({"ok": True, "ids": [id1, id2]})
@bp.route("/cluster/<int:cid>/merge", methods=["POST"])
def merge(cid):
s = _store()
other = _body().get("other_id")
if other is None:
s.close()
return jsonify({"error": "other_id required"}), 400
new_id = review.merge(s, cid, int(other))
s.close()
return jsonify({"ok": True, "id": new_id})
@bp.route("/cluster/<int:cid>/member", methods=["POST"])
def member(cid):
s = _store()
body = _body()
review.set_member(s, cid, body["asset_id"], bool(body.get("included")))
s.close()
return jsonify({"ok": True})
@bp.route("/approve-high-confidence", methods=["POST"])
def approve_high_confidence():
s = _store()
threshold = float(_body().get("threshold", 0.75))
n = review.approve_high_confidence(s, threshold)
s.close()
return jsonify({"approved": n})
@bp.route("/cluster/<int:cid>/apply", methods=["POST"])
def apply_one(cid):
# Lazy import: app.writeback is owned by a later task and may be absent
# at app-startup; importing here keeps the blueprint importable regardless.
from app.writeback import apply_cluster
with _store() as s: # close even if the Immich write-back raises
res = apply_cluster(_client(), s, cid)
return jsonify(res)
@bp.route("/apply-all", methods=["POST"])
def apply_everything():
# Lazy import: see apply_one above.
from app.writeback import apply_all
with _store() as s: # close even if the Immich write-back raises
results = apply_all(_client(), s)
return jsonify({"results": results})
+184
View File
@@ -0,0 +1,184 @@
function clusterReview() {
return {
...photoGrid(),
selected: null,
clusterIds: [],
failures: [],
init() {
this.clusterIds = [...document.querySelectorAll('.cluster-row')]
.map(el => parseInt(el.dataset.clusterId, 10));
this.selected = this.clusterIds.length ? this.clusterIds[0] : null;
document.body.addEventListener('htmx:afterSwap', (e) => {
if (e.target.id === 'detail') {
this.hideIndicator();
this.selectFirst();
}
});
},
// --- F16: loading indicator for programmatic swaps -----------------
showIndicator() {
const el = document.getElementById('detail-indicator');
if (el) el.classList.add('htmx-request');
},
hideIndicator() {
const el = document.getElementById('detail-indicator');
if (el) el.classList.remove('htmx-request');
},
selectCluster(id) {
this.selected = id;
this.showIndicator();
htmx.ajax('GET', `/cluster/${id}`, { target: '#detail' });
},
moveCluster(dir) {
const n = this.clusterIds.length;
if (!n) return;
const i = this.clusterIds.indexOf(this.selected);
const j = ((i + dir) % n + n) % n; // wrap around (mirrors nextPendingId)
if (this.clusterIds[j] != null) this.selectCluster(this.clusterIds[j]);
},
// --- F3: in-place rail status + advance to next pending ------------
statusClass(status) {
return ({
pending: 'badge-ghost', approved: 'badge-success', non_trip: 'badge-neutral',
skipped: 'badge-warning', merged: 'badge-info', split: 'badge-info',
})[status] || 'badge-ghost';
},
railStatusEl(id) {
return document.querySelector(`.cluster-status[data-cluster-id="${id}"]`);
},
railStatusText(id) {
const el = this.railStatusEl(id);
return el ? el.textContent.trim() : '';
},
updateRailStatus(id, status) {
const el = this.railStatusEl(id);
if (el) el.innerHTML =
`<span class="badge badge-sm ${this.statusClass(status)}">${status}</span>`;
},
nextPendingId(fromId) {
const n = this.clusterIds.length;
const start = Math.max(0, this.clusterIds.indexOf(fromId));
for (let k = 1; k <= n; k++) {
const id = this.clusterIds[(start + k) % n];
if (id !== fromId && this.railStatusText(id) === 'pending') return id;
}
return null;
},
// Update the acted cluster's rail badge in place (keeping the new status
// text visible) and advance the detail pane to the next pending cluster
// instead of bouncing back to the attention-queue head.
afterDecision(id, status) {
this.updateRailStatus(id, status);
const next = this.nextPendingId(id);
if (next != null) this.selectCluster(next);
else this.selectCluster(id); // no pending left: refresh current detail
},
nameValue() {
const el = document.getElementById('cluster-name');
return el ? el.value : '';
},
async post(path, body) {
const res = await fetch(path, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
});
return res.json();
},
async approve() {
const acted = this.selected;
const r = await this.post(`/cluster/${acted}/approve`, { name: this.nameValue() });
this.afterDecision(acted, r.status || 'approved');
},
async nonTrip() {
const acted = this.selected;
const r = await this.post(`/cluster/${acted}/non-trip`);
this.afterDecision(acted, r.status || 'non_trip');
},
async skip() {
const acted = this.selected;
const r = await this.post(`/cluster/${acted}/skip`);
this.afterDecision(acted, r.status || 'skipped');
},
async split() {
// F7: require a focused boundary photo before splitting.
if (!this.focused) { alert('Focus the first photo of the second trip, then Split.'); return; }
await this.post(`/cluster/${this.selected}/split`, { boundary_asset_id: this.focused.dataset.assetId });
location.reload(); // split restructures the rail: full refresh is correct
},
async merge(otherId) {
// F17: merge terminally marks both clusters merged with no UI un-merge.
if (!confirm('Merge these two clusters? This cannot be undone in the UI.')) return;
await this.post(`/cluster/${this.selected}/merge`, { other_id: otherId });
location.reload(); // merge restructures the rail: full refresh is correct
},
async setMember(assetId, included) {
await this.post(`/cluster/${this.selected}/member`, { asset_id: assetId, included });
this.selectCluster(this.selected);
},
async approveHighConfidence() {
// F19: show a pre-action count and confirm.
const threshold = 0.75;
const n = this.clusterIds.filter(id => {
const row = document.querySelector(`.cluster-row[data-cluster-id="${id}"]`);
return row && row.querySelector('.badge-success[title]'); // confidence "high"
}).length;
if (!confirm(`Approve ${n} high-confidence cluster(s) (>= ${threshold})?`)) return;
const r = await this.post('/approve-high-confidence', { threshold });
alert(`Approved ${r.approved} cluster(s).`);
location.reload();
},
async apply() {
// Write-back needs explicit confirmation (mirrors applyAll/merge).
if (!confirm('Apply this clusters decision to Immich?')) return;
const r = await this.post(`/cluster/${this.selected}/apply`, {});
alert(`Applied ${r.succeeded.length}, failed ${r.failed.length}.`);
},
async applyAll() {
if (!confirm('Apply all approved decisions to Immich?')) return;
const r = await this.post('/apply-all', {});
const results = r.results || [];
let ok = 0, bad = 0;
this.failures = [];
for (const x of results) {
const succeeded = x.succeeded || [];
const failed = x.failed || [];
ok += succeeded.length;
bad += failed.length;
if (failed.length) {
const id = x.cluster_id != null ? x.cluster_id : x.id;
const row = document.querySelector(`.cluster-row[data-cluster-id="${id}"]`);
if (row) row.classList.add('badge-error', 'ring-1', 'ring-error');
this.failures.push({
id,
name: row ? row.dataset.clusterName : `cluster ${id}`,
failed: failed.length,
});
}
}
alert(`Applied ${ok} write(s), ${bad} failure(s).`);
},
onKey(e) {
if (e.target.tagName === 'INPUT') return;
const k = e.key.toLowerCase();
if (e.key === '[') { e.preventDefault(); this.moveCluster(-1); }
else if (e.key === ']') { e.preventDefault(); this.moveCluster(1); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); this.navigate(-1); }
else if (e.key === 'ArrowRight') { e.preventDefault(); this.navigate(1); }
else if (e.key === 'Enter') { if (this.focused) this.openLightbox(this.focused); }
else if (e.key === 'Escape') { this.closeLightbox(); }
else if (k === 'a') { this.approve(); }
else if (k === 'n') { this.nonTrip(); }
else if (k === 's') { if (this.focused) this.split(); } // F7: suppress until focused
else if (k === 'x') { this.skip(); }
},
};
}
@@ -0,0 +1,41 @@
{% from "macros.html" import lightbox %}
<div data-cluster-id="{{ c.id }}">
<div class="flex flex-wrap items-center gap-2 mb-3">
<input id="cluster-name" class="input input-bordered input-sm"
value="{{ c.decided_name or c.suggested_name }}">
<button class="btn btn-sm btn-success" @click="approve()">Approve (A)</button>
<button class="btn btn-sm" @click="nonTrip()">Non-trip (N)</button>
<!-- F7: Split disabled until a boundary photo is focused -->
<button class="btn btn-sm" :disabled="!focused" @click="split()">Split (S)</button>
<button class="btn btn-sm" @click="skip()">Skip (X)</button>
<button class="btn btn-sm btn-ghost" @click="apply()">Apply</button>
{% if prev_id %}<button class="btn btn-xs" @click="merge({{ prev_id }})">⤺ merge prev</button>{% endif %}
{% if next_id %}<button class="btn btn-xs" @click="merge({{ next_id }})">merge next ⤻</button>{% endif %}
</div>
<!-- F7: visible instruction for Split -->
<div class="text-xs opacity-60 mb-2" x-show="!focused">
Focus the first photo of the second trip (click it or use ← →), then Split.
</div>
<div class="text-sm opacity-60 mb-2">
{{ c.start_at[:10] }} → {{ c.end_at[:10] }} · {{ members | length }} assets · status {{ c.status }}
</div>
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{% for a, m in members %}
<div class="photo-card relative cursor-pointer rounded-lg overflow-hidden border-2
{% if m.flagged_coverage %}border-info{% elif m.is_outlier %}border-warning{% else %}border-transparent{% endif %}"
data-asset-id="{{ a.immich_id }}" data-included="{{ '1' if m.included else '' }}"
tabindex="0" @click="openLightbox($el)" @focus="select($el)">
<img src="/thumb/{{ a.immich_id }}" class="w-full aspect-square object-cover" loading="lazy" alt="">
{% if m.flagged_coverage %}
<button class="absolute bottom-1 left-1 badge badge-xs badge-info"
@click.stop="setMember('{{ a.immich_id }}', true)">+ include</button>
{% elif m.is_outlier %}
<button class="absolute bottom-1 left-1 badge badge-xs badge-warning"
@click.stop="setMember('{{ a.immich_id }}', false)"> exclude</button>
{% endif %}
{% if not m.included %}<div class="absolute inset-0 bg-black/50 pointer-events-none"></div>{% endif %}
</div>
{% endfor %}
</div>
{{ lightbox() }}
</div>
@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% from "macros.html" import confidence_badge, status_badge %}
{% block title %}trip-cluster{% endblock %}
{% block navbar_title %}trip-cluster{% endblock %}
{% block content %}
{% if stats.clusters == 0 %}
<div class="hero py-20"><div class="hero-content text-center"><div>
<h1 class="text-2xl font-bold">No clusters yet</h1>
{% if stats.assets == 0 %}
<p class="opacity-70 mt-2">Nothing ingested. Run
<code>categorize ingest</code> then <code>categorize cluster</code>.</p>
{% else %}
<p class="opacity-70 mt-2">{{ stats.assets }} assets ingested, but this scope
produced no clusters. Try a wider <code>categorize ingest</code> scope.</p>
{% endif %}
</div></div></div>
{% else %}
<div x-data="clusterReview()" @keydown.window="onKey($event)" class="flex gap-4">
<div class="w-72 shrink-0 max-h-[85vh] overflow-y-auto">
<div class="flex items-center justify-between mb-1">
<span class="font-bold">Clusters</span>
<button class="btn btn-xs btn-primary" @click="approveHighConfidence()">Approve high-conf</button>
</div>
<!-- F8: explain the attention sort -->
<p class="text-xs opacity-60 mb-2">Needs attention first — lowest-confidence pending.</p>
{% if stats.pending == 0 %}
<div class="alert alert-success text-xs mb-2">All reviewed — “Apply all” or <code>categorize apply</code>.</div>
{% endif %}
<!-- F4: write-failed clusters surfaced here after Apply all -->
<div x-show="failures.length" class="alert alert-error text-xs mb-2 flex-col items-start">
<span class="font-semibold">Write failures:</span>
<ul class="list-disc ml-4">
<template x-for="f in failures" :key="f.id">
<li><span x-text="f.name"></span><span x-text="f.failed"></span> failed</li>
</template>
</ul>
</div>
{% for c in clusters %}
<div class="cluster-row block p-2 rounded cursor-pointer hover:bg-base-300"
:class="selected == {{ c.id }} && 'bg-base-300'"
data-cluster-id="{{ c.id }}" data-cluster-name="{{ c.decided_name or c.suggested_name }}"
@click="selectCluster({{ c.id }})">
<div class="flex items-center justify-between gap-1">
<span class="truncate text-sm font-medium">{{ c.decided_name or c.suggested_name }}</span>
{{ confidence_badge(c.confidence) }}
</div>
<div class="flex items-center gap-1 mt-1">
<span class="cluster-status" data-cluster-id="{{ c.id }}">{{ status_badge(c.status) }}</span>
<span class="badge badge-xs">{{ c.count }}</span>
<span class="text-xs opacity-50">{{ c.start_at[:10] }}</span>
</div>
</div>
{% endfor %}
<button class="btn btn-xs btn-block mt-3" @click="applyAll()">Apply all approved</button>
</div>
<div class="flex-1 relative">
<!-- F16: loading indicator for declarative + programmatic swaps -->
<div id="detail-indicator" class="htmx-indicator absolute top-2 right-2 z-10">
<span class="loading loading-spinner loading-md"></span>
</div>
<div id="detail" class="flex-1"
hx-get="/cluster/{{ clusters[0].id }}" hx-trigger="load" hx-target="#detail"
hx-indicator="#detail-indicator"></div>
</div>
</div>
<div class="fixed bottom-2 right-2 text-xs opacity-50">
[ ] cluster · ← → grid · Enter open · A approve · N non-trip · S split (focus a photo) · X skip · Esc close
</div>
{% endif %}
{% endblock %}
{% block extra_scripts %}<script src="/static/app.js"></script>{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
from photoflow.immich import pipeline
APPLYABLE = ("approved", "non_trip", "skipped")
def _apply_tag(client, store, asset_ids, action, tag):
todo = [a for a in asset_ids if not store.already_applied(a, action, tag)]
if not todo:
return [], []
try:
# upsert_tag is part of the write: a failure here must be caught and
# recorded like a tag_assets failure, not propagate out of apply_cluster
# (which would abort the whole apply_all batch and skip later clusters).
tag_id = client.upsert_tag(tag)
client.tag_assets(tag_id, todo)
except Exception as e: # noqa: BLE001 — recorded, surfaced
for a in todo:
store.log_writeback(a, action, tag, f"error:{e}")
return [], [(a, str(e)) for a in todo]
for a in todo:
store.log_writeback(a, action, tag, "ok")
return todo, []
def apply_cluster(client, store, cluster_id) -> dict:
c = store.get_cluster(cluster_id)
if c is None or c.status not in APPLYABLE:
return {"cluster_id": cluster_id, "status": c.status if c else None,
"succeeded": [], "failed": []}
included = [a.immich_id for a, m in store.cluster_members(cluster_id) if m.included]
succeeded, failed = [], []
if c.status == "approved":
tag = c.decided_name or c.suggested_name
ok, fail = _apply_tag(client, store, included, "trip", tag)
succeeded += ok
failed += fail
elif c.status == "non_trip":
ok, fail = _apply_tag(client, store, included, "non-trip", pipeline.NON_TRIP)
succeeded += ok
failed += fail
# 'skipped': no content/non-trip tag, only processed below.
failed_ids = {i for i, _ in failed}
proc_targets = [a for a in included if a not in failed_ids]
if proc_targets:
# Surface processed-marker failures too — a discarded return here makes a
# failed _pipeline/processed write look like success in the CLI/UI summary.
_, proc_fail = _apply_tag(client, store, proc_targets, "processed", pipeline.PROCESSED)
failed += proc_fail
for a in proc_targets:
if store.already_applied(a, "processed", pipeline.PROCESSED):
store.mark_processed(a)
return {"cluster_id": cluster_id, "status": c.status,
"succeeded": succeeded, "failed": failed}
def apply_all(client, store) -> list:
return [apply_cluster(client, store, c.id)
for c in store.all_clusters() if c.status in APPLYABLE]
+5
View File
@@ -0,0 +1,5 @@
import sys
from app.cli import main
if __name__ == "__main__":
sys.exit(main())
+16
View File
@@ -0,0 +1,16 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "trip-cluster"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["photoflow", "flask==3.1.0", "requests==2.32.3", "Pillow==11.0.0"]
[tool.setuptools.packages.find]
where = ["."]
include = ["app*"]
[tool.setuptools.package-data]
"app" = ["templates/*.html", "static/*.js"]
+1
View File
@@ -0,0 +1 @@
# Editable installs put `app` and `photoflow` on sys.path; nothing extra needed.
@@ -0,0 +1,61 @@
from photoflow.core import Store
from photoflow.core.models import Asset
from photoflow.immich import pipeline
from app.cluster_run import run_cluster
def _store(tmp_path):
return Store(str(tmp_path / "t.db")).connect()
def test_run_cluster_seeds_bounded_tag_not_people_tag(tmp_path):
s = _store(tmp_path)
# "Italy 2019" spans 2 days -> seeds; "Mum" spans years -> not a seed.
rows = [("a", "2019-06-01T10:00:00", ["Italy 2019", "Mum"]),
("b", "2019-06-02T10:00:00", ["Italy 2019"]),
("c", "2010-01-01T10:00:00", ["Mum"]),
("d", "2022-01-01T10:00:00", ["Mum"])]
for i, t, tags in rows:
s.upsert_asset(Asset(immich_id=i, taken_at=t))
s.set_asset_tags(i, tags)
res = run_cluster(s)
seeded = [c for c in s.all_clusters() if c.suggested_name == "Italy 2019"]
assert len(seeded) == 1
assert sorted(x.immich_id for x, _ in s.cluster_members(seeded[0].id)) == ["a", "b"]
assert res["clusters"] >= 1
s.close()
def test_run_cluster_excludes_processed_and_replaces(tmp_path):
s = _store(tmp_path)
s.upsert_asset(Asset(immich_id="a", taken_at="2019-06-01T10:00:00"))
s.upsert_asset(Asset(immich_id="p", taken_at="2019-06-01T11:00:00"))
s.set_asset_tags("a", [])
s.set_asset_tags("p", [pipeline.PROCESSED])
s.mark_processed("p")
run_cluster(s)
run_cluster(s) # idempotent replace — not doubled
all_member_ids = [x.immich_id for c in s.all_clusters()
for x, _ in s.cluster_members(c.id)]
assert "p" not in all_member_ids
assert all_member_ids.count("a") == 1
s.close()
def test_run_cluster_short_span_non_dominant_tag_does_not_seed(tmp_path):
s = _store(tmp_path)
# "Lunch" is on 2 photos within a ~30-min window, but that same window is
# full of untagged photos -> the tag does NOT dominate its window
# (2/5 = 0.4 < 0.5), so it must not seed even though its span is tiny.
rows = [("x", "2020-03-01T12:00:00", ["Lunch"]),
("y", "2020-03-01T12:30:00", ["Lunch"]),
("u1", "2020-03-01T12:05:00", []),
("u2", "2020-03-01T12:15:00", []),
("u3", "2020-03-01T12:25:00", [])]
for i, t, tags in rows:
s.upsert_asset(Asset(immich_id=i, taken_at=t))
s.set_asset_tags(i, tags)
run_cluster(s)
seeded = [c for c in s.all_clusters() if c.suggested_name == "Lunch"]
assert seeded == []
s.close()
@@ -0,0 +1,62 @@
from photoflow.core.models import Asset
from app.clustering import cluster_assets
def _a(i, taken, gps=False, city=None):
return Asset(immich_id=i, taken_at=taken,
gps_lat=45.0 if gps else None, gps_lon=12.0 if gps else None,
place_city=city)
def test_seed_tag_forms_one_cluster_not_gap_split():
# Two assets months apart but sharing a trip tag -> ONE seeded cluster.
assets = [_a("a", "2019-06-01T10:00:00"), _a("b", "2019-09-01T10:00:00")]
tags = {"a": ["Italy 2019"], "b": ["Italy 2019"]}
clusters = cluster_assets(assets, tags, {"Italy 2019"})
assert len(clusters) == 1
c = clusters[0]
assert c.seed_tag == "Italy 2019" and sorted(c.member_ids) == ["a", "b"]
assert c.confidence >= 0.9 and c.suggested_name == "Italy 2019"
def test_sparse_old_regime_splits_on_adaptive_threshold():
# ~1 day intra-trip gaps; trips separated by 10 days (< 14d hard cap),
# so only the adaptive rule can split them.
a = [_a(f"a{i}", f"2008-06-0{i+1}T12:00:00") for i in range(5)] # Jun 1..5
b = [_a(f"b{i}", f"2008-06-1{i+5}T12:00:00") for i in range(3)] # Jun 15..17
assets = a + b
tags = {x.immich_id: [] for x in assets}
clusters = cluster_assets(assets, tags, set())
assert len(clusters) == 2
assert sorted(clusters[0].member_ids) == ["a0", "a1", "a2", "a3", "a4"]
def test_dense_recent_regime_splits_on_adaptive_threshold():
# Hourly bursts within a day; 2-day gap between days.
day1 = [_a(f"d{i}", f"2024-03-10T{10+i:02d}:00:00") for i in range(4)]
day3 = [_a(f"e{i}", f"2024-03-12T{10+i:02d}:00:00") for i in range(4)]
assets = day1 + day3
tags = {x.immich_id: [] for x in assets}
clusters = cluster_assets(assets, tags, set())
assert len(clusters) == 2
assert sorted(clusters[0].member_ids) == ["d0", "d1", "d2", "d3"]
def test_location_anchor_names_and_gps_confidence():
assets = [_a("a", "2020-05-01T10:00:00", gps=True, city="Kiev"),
_a("b", "2020-05-01T12:00:00", gps=True, city="Kiev"),
_a("c", "2020-05-01T14:00:00", gps=True, city="Kiev"),
_a("d", "2020-05-01T16:00:00", gps=True, city="Kiev"),
_a("e", "2020-05-01T18:00:00", gps=True, city="Kiev")]
tags = {x.immich_id: [] for x in assets}
c = cluster_assets(assets, tags, set())[0]
assert c.suggested_name == "Kiev"
assert c.confidence > 0.6 # full GPS lifts confidence
assert c.kind_guess == "trip"
def test_small_scattered_cluster_marked_everyday():
assets = [_a("a", "2015-01-01T10:00:00"), _a("b", "2015-01-01T11:00:00")]
tags = {"a": [], "b": []}
c = cluster_assets(assets, tags, set())[0]
assert c.kind_guess == "everyday"
+28
View File
@@ -0,0 +1,28 @@
import os
import pytest
from app.config import load_config, ConfigError
def test_missing_required_raises():
with pytest.raises(ConfigError) as e:
load_config({"IMMICH_URL": "http://x"})
assert "IMMICH_API_KEY" in e.value.missing
def test_anthropic_optional_and_paths(tmp_path):
cfg = load_config({"IMMICH_URL": "http://x/", "IMMICH_API_KEY": "k",
"DATA_DIR": str(tmp_path)})
assert cfg.immich_url == "http://x" # trailing slash stripped
assert cfg.anthropic_api_key == "" # optional in M1
assert cfg.db_path == os.path.join(str(tmp_path), "trip-cluster.db")
assert cfg.thumbs_dir == os.path.join(str(tmp_path), "thumbs")
def test_immich_db_url_optional():
# DSN is optional — only the pgvector spike / M1.5 need it; REST creds stay required.
cfg = load_config({"IMMICH_URL": "http://x", "IMMICH_API_KEY": "k"})
assert cfg.immich_db_url is None
cfg = load_config({"IMMICH_URL": "http://x", "IMMICH_API_KEY": "k",
"IMMICH_DB_URL": " postgresql://u:p@h:5432/immich "})
assert cfg.immich_db_url == "postgresql://u:p@h:5432/immich" # trimmed
+48
View File
@@ -0,0 +1,48 @@
from photoflow.core.models import Asset
from app.clustering import CandidateCluster
from app.coverage import coverage_members
def _a(i, taken):
return Asset(immich_id=i, taken_at=taken)
def test_coverage_candidate_inside_seeded_window():
members = [_a("a", "2019-06-01T10:00:00"), _a("b", "2019-06-01T12:00:00"),
_a("c", "2019-06-02T10:00:00")]
intruder = _a("x", "2019-06-01T13:00:00") # in window, untagged
outside = _a("y", "2019-07-01T10:00:00") # out of window
by_id = {m.immich_id: m for m in members + [intruder, outside]}
cand = CandidateCluster(member_ids=["a", "b", "c"], start_at="2019-06-01T10:00:00",
end_at="2019-06-02T10:00:00", suggested_name="Italy 2019",
confidence=0.95, kind_guess="trip", seed_tag="Italy 2019")
out = coverage_members(cand, by_id)
flagged = {m.immich_id for m in out if m.flagged_coverage}
assert flagged == {"x"} # only the in-window intruder
assert all(not m.included for m in out if m.flagged_coverage)
def test_outlier_member_far_from_bulk():
members = [_a("a", "2019-06-01T10:00:00"), _a("a2", "2019-06-01T11:00:00"),
_a("a3", "2019-06-01T12:00:00"),
_a("z", "2019-09-01T10:00:00")] # tagged but months away
by_id = {m.immich_id: m for m in members}
cand = CandidateCluster(member_ids=["a", "a2", "a3", "z"],
start_at="2019-06-01T10:00:00", end_at="2019-09-01T10:00:00",
suggested_name="Italy 2019", confidence=0.95,
kind_guess="trip", seed_tag="Italy 2019")
out = coverage_members(cand, by_id)
outliers = {m.immich_id for m in out if m.is_outlier}
assert outliers == {"z"}
def test_non_seeded_cluster_has_no_coverage_or_outliers():
members = [_a("a", "2019-06-01T10:00:00"), _a("b", "2019-06-01T12:00:00")]
intruder = _a("x", "2019-06-01T11:00:00")
by_id = {m.immich_id: m for m in members + [intruder]}
cand = CandidateCluster(member_ids=["a", "b"], start_at="2019-06-01T10:00:00",
end_at="2019-06-01T12:00:00", suggested_name="Trip",
confidence=0.4, kind_guess="everyday", seed_tag=None)
out = coverage_members(cand, by_id)
assert {m.immich_id for m in out} == {"a", "b"}
assert not any(m.flagged_coverage or m.is_outlier for m in out)
+140
View File
@@ -0,0 +1,140 @@
import os
from photoflow.core import Store
from photoflow.immich import pipeline
from app.ingest import run_ingest
class FakeImmich:
def __init__(self, assets, tag_map=None, thumb=b"\xff\xd8\xffjpeg"):
self._assets = assets
self._tag_map = tag_map or {}
self._thumb = thumb
self.searches = []
def resolve_tag_id(self, name):
return self._tag_map.get(name)
def search_assets(self, **kwargs):
self.searches.append(kwargs)
return list(self._assets)
def download_thumbnail(self, asset_id):
return self._thumb
def _asset(i, taken, tags=None, updated="2026-01-01T00:00:00Z"):
return {"id": i, "original_filename": f"{i}.jpg", "taken_at": taken,
"gps_lat": None, "gps_lon": None, "place_city": None,
"place_country": None, "type": "IMAGE", "tags": tags or [],
"rating": 0, "updated_at": updated}
def test_ingest_upserts_assets_tags_and_thumbs(tmp_path):
store = Store(str(tmp_path / "t.db")).connect()
thumbs = str(tmp_path / "thumbs")
os.makedirs(thumbs, exist_ok=True)
client = FakeImmich([_asset("a", "2019-06-01", tags=["Italy 2019"]),
_asset("b", "2019-06-02")])
res = run_ingest(client, store, thumbs, date_from="2019-01-01", date_to="2020-01-01")
assert res["fetched"] == 2
assert store.get_asset("a").taken_at == "2019-06-01"
assert store.asset_tags("a") == ["Italy 2019"]
assert os.path.exists(os.path.join(thumbs, "a.jpg"))
assert store.get_meta("last_ingest_at") == "2026-01-01T00:00:00Z"
store.close()
def test_ingest_marks_processed_from_pipeline_tag(tmp_path):
store = Store(str(tmp_path / "t.db")).connect()
thumbs = str(tmp_path / "thumbs"); os.makedirs(thumbs, exist_ok=True)
client = FakeImmich([_asset("a", "2019-06-01", tags=[pipeline.PROCESSED])])
res = run_ingest(client, store, thumbs)
assert res["processed_marked"] == 1
assert store.get_asset("a").processed is True
store.close()
def test_ingest_incremental_passes_updated_after(tmp_path):
store = Store(str(tmp_path / "t.db")).connect()
thumbs = str(tmp_path / "thumbs"); os.makedirs(thumbs, exist_ok=True)
store.set_meta("last_ingest_at", "2026-05-01T00:00:00Z")
client = FakeImmich([_asset("a", "2019-06-01")])
run_ingest(client, store, thumbs)
assert client.searches[0].get("updated_after") == "2026-05-01T00:00:00Z"
# --full ignores it
client2 = FakeImmich([_asset("a", "2019-06-01")])
run_ingest(client2, store, thumbs, full=True)
assert client2.searches[0].get("updated_after") is None
store.close()
def test_ingest_subset_and_tag_resolution(tmp_path):
store = Store(str(tmp_path / "t.db")).connect()
thumbs = str(tmp_path / "thumbs"); os.makedirs(thumbs, exist_ok=True)
client = FakeImmich([_asset("a", "2019-06-01"), _asset("b", "2019-06-02")],
tag_map={"Italy 2019": "t1"})
res = run_ingest(client, store, thumbs, tag="Italy 2019", subset=1)
assert res["fetched"] == 1
assert client.searches[0].get("tag_ids") == ["t1"]
store.close()
def test_ingest_resilient_to_thumb_failure_and_zero_byte(tmp_path):
store = Store(str(tmp_path / "t.db")).connect()
thumbs = str(tmp_path / "thumbs"); os.makedirs(thumbs, exist_ok=True)
class FailThenOk(FakeImmich):
def download_thumbnail(self, asset_id):
if asset_id == "a":
raise RuntimeError("boom")
return self._thumb
# Pre-existing 0-byte file for "b" must be treated as missing and refetched.
with open(os.path.join(thumbs, "b.jpg"), "wb"):
pass
client = FailThenOk([_asset("a", "2019-06-01"), _asset("b", "2019-06-02")])
res = run_ingest(client, store, thumbs)
# The run completes despite "a" failing.
assert res["fetched"] == 2
# No 0-byte file left for the failed asset.
assert not os.path.exists(os.path.join(thumbs, "a.jpg"))
# The 0-byte file for "b" was replaced with real bytes.
assert os.path.getsize(os.path.join(thumbs, "b.jpg")) > 0
store.close()
def test_ingest_watermark_not_advanced_past_failed_asset(tmp_path):
# A later successful asset must NOT advance last_ingest_at past an earlier
# asset whose thumbnail failed, or that asset is stranded on the next
# incremental run (review finding #9).
store = Store(str(tmp_path / "t.db")).connect()
thumbs = str(tmp_path / "thumbs"); os.makedirs(thumbs, exist_ok=True)
class FailOld(FakeImmich):
def download_thumbnail(self, asset_id):
if asset_id == "old":
raise RuntimeError("boom")
return self._thumb
client = FailOld([_asset("old", "2008-06-01", updated="2026-01-01T00:00:00Z"),
_asset("new", "2024-03-01", updated="2026-03-01T00:00:00Z")])
run_ingest(client, store, thumbs)
assert store.get_asset("old") is None and store.get_asset("new") is not None
wm = store.get_meta("last_ingest_at")
# cursor must stay below the failed asset (here: not advanced at all)
assert wm is None or wm < "2026-01-01T00:00:00Z"
assert wm != "2026-03-01T00:00:00Z"
store.close()
def test_ingest_subset_run_does_not_advance_watermark(tmp_path):
# --subset is a sampling run; it must not move the incremental cursor or it
# would strand the un-fetched remainder.
store = Store(str(tmp_path / "t.db")).connect()
thumbs = str(tmp_path / "thumbs"); os.makedirs(thumbs, exist_ok=True)
client = FakeImmich([_asset("a", "2019-06-01", updated="2026-02-01T00:00:00Z"),
_asset("b", "2019-06-02", updated="2026-02-02T00:00:00Z")])
run_ingest(client, store, thumbs, subset=1)
assert store.get_meta("last_ingest_at") is None
store.close()
+59
View File
@@ -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()
+100
View File
@@ -0,0 +1,100 @@
import os
from app import create_app
from app.config import Config
from photoflow.core import Store
from photoflow.core.models import Asset, Cluster, ClusterMember
def _seed_store(cfg):
s = Store(cfg.db_path).connect()
for i, t in [("a", "2019-06-01"), ("b", "2019-06-02"), ("c", "2019-07-10")]:
s.upsert_asset(Asset(immich_id=i, taken_at=t))
cid = s.insert_cluster(
Cluster(start_at="2019-06-01", end_at="2019-06-02", suggested_name="Venice",
confidence=0.9, kind_guess="trip", status="pending"),
[ClusterMember(cluster_id=0, immich_id="a"),
ClusterMember(cluster_id=0, immich_id="b")])
other = s.insert_cluster(
Cluster(start_at="2019-07-10", end_at="2019-07-10", suggested_name="Rome",
confidence=0.3, kind_guess="trip", status="pending"),
[ClusterMember(cluster_id=0, immich_id="c")])
s.close()
return cid, other
def _app(tmp_path):
cfg = Config(immich_url="http://x", immich_api_key="k",
anthropic_api_key="", data_dir=str(tmp_path))
app = create_app(cfg)
app.config.update(TESTING=True)
return app
def test_health(tmp_path):
assert _app(tmp_path).test_client().get("/health").data == b"ok"
def test_thumb_served(tmp_path):
thumbs = os.path.join(str(tmp_path), "thumbs")
os.makedirs(thumbs, exist_ok=True)
with open(os.path.join(thumbs, "a.jpg"), "wb") as f:
f.write(b"\xff\xd8\xffjpeg")
client = _app(tmp_path).test_client()
r = client.get("/thumb/a")
assert r.status_code == 200 and r.mimetype == "image/jpeg"
assert _app(tmp_path).test_client().get("/thumb/missing").status_code == 404
def test_index_lists_clusters(tmp_path):
app = _app(tmp_path)
_seed_store(app.config["APP_CONFIG"])
r = app.test_client().get("/")
assert r.status_code == 200 and b"Venice" in r.data and b"Rome" in r.data
def test_index_empty_state(tmp_path):
r = _app(tmp_path).test_client().get("/")
assert r.status_code == 200 and b"No clusters yet" in r.data
def test_detail_and_approve(tmp_path):
app = _app(tmp_path)
cid, _ = _seed_store(app.config["APP_CONFIG"])
client = app.test_client()
d = client.get(f"/cluster/{cid}")
assert d.status_code == 200 and b"/thumb/a" in d.data
r = client.post(f"/cluster/{cid}/approve", json={"name": "Venezia"})
assert r.get_json()["status"] == "approved"
s = Store(app.config["APP_CONFIG"].db_path).connect()
assert s.get_cluster(cid).decided_name == "Venezia"
s.close()
def test_member_toggle_and_high_confidence(tmp_path):
app = _app(tmp_path)
cid, _ = _seed_store(app.config["APP_CONFIG"])
client = app.test_client()
client.post(f"/cluster/{cid}/member", json={"asset_id": "b", "included": False})
s = Store(app.config["APP_CONFIG"].db_path).connect()
assert {m.immich_id: m.included for _, m in s.cluster_members(cid)}["b"] is False
s.close()
r = client.post("/approve-high-confidence", json={"threshold": 0.75})
assert r.get_json()["approved"] == 1 # only the 0.9 cluster
def test_apply_all_with_injected_client(tmp_path):
app = _app(tmp_path)
cid, _ = _seed_store(app.config["APP_CONFIG"])
class FakeImmich:
def __init__(self): self.tagged = []
def upsert_tag(self, name): return f"id:{name}"
def tag_assets(self, tid, ids): self.tagged.append((tid, list(ids)))
fake = FakeImmich()
app.config["IMMICH_FACTORY"] = lambda: fake
client = app.test_client()
client.post(f"/cluster/{cid}/approve", json={"name": "Venice"})
r = client.post("/apply-all", json={})
assert r.status_code == 200
assert any(tid == "id:Venice" for tid, _ in fake.tagged)
+148
View File
@@ -0,0 +1,148 @@
from photoflow.core import Store
from photoflow.core.models import Asset, Cluster, ClusterMember
from photoflow.immich import pipeline
from app.writeback import apply_cluster, apply_all
class FakeImmich:
def __init__(self):
self.tagged = []
self.fail_tag_id = None
self.fail_upsert = None # tag name whose upsert_tag should raise
self._ids = {}
def upsert_tag(self, name):
if self.fail_upsert is not None and name == self.fail_upsert:
raise RuntimeError("upsert boom")
self._ids.setdefault(name, f"id:{name}")
return self._ids[name]
def tag_assets(self, tag_id, ids):
if self.fail_tag_id is not None and tag_id == self.fail_tag_id:
raise RuntimeError("boom")
self.tagged.append((tag_id, list(ids)))
def _store(tmp_path):
s = Store(str(tmp_path / "t.db")).connect()
for i in ("a", "b", "x"):
s.upsert_asset(Asset(immich_id=i, taken_at="2019-06-01"))
return s
def _approved(s, name="Venice"):
cid = s.insert_cluster(
Cluster(start_at="2019-06-01", end_at="2019-06-02", suggested_name=name,
status="approved", decided_name=name),
[ClusterMember(cluster_id=0, immich_id="a"),
ClusterMember(cluster_id=0, immich_id="b"),
ClusterMember(cluster_id=0, immich_id="x", included=False, flagged_coverage=True)])
return cid
def test_apply_approved_tags_included_then_processed(tmp_path):
s = _store(tmp_path)
cid = _approved(s)
client = FakeImmich()
res = apply_cluster(client, s, cid)
assert sorted(res["succeeded"]) == ["a", "b"] and res["failed"] == []
# trip tag on a,b ; processed on a,b ; x (excluded) never tagged
assert ("id:Venice", ["a", "b"]) in client.tagged
assert ("id:_pipeline/processed", ["a", "b"]) in client.tagged
assert all("x" not in ids for _, ids in client.tagged)
assert s.get_asset("a").processed is True
s.close()
def test_apply_is_idempotent(tmp_path):
s = _store(tmp_path)
cid = _approved(s)
client = FakeImmich()
apply_cluster(client, s, cid)
before = len(client.tagged)
apply_cluster(client, s, cid) # second run writes nothing new
assert len(client.tagged) == before
s.close()
def test_partial_failure_leaves_retryable(tmp_path):
s = _store(tmp_path)
cid = _approved(s)
client = FakeImmich()
client.fail_tag_id = "id:Venice" # trip tag write fails
res = apply_cluster(client, s, cid)
assert res["succeeded"] == [] and sorted(i for i, _ in res["failed"]) == ["a", "b"]
assert s.get_asset("a").processed is False # not marked processed on failure
assert s.already_applied("a", "trip", "Venice") is False # retryable
s.close()
def test_apply_non_trip(tmp_path):
s = _store(tmp_path)
cid = s.insert_cluster(
Cluster(start_at="2019-06-01", end_at="2019-06-02", status="non_trip"),
[ClusterMember(cluster_id=0, immich_id="a")])
client = FakeImmich()
apply_cluster(client, s, cid)
assert ("id:_pipeline/non-trip", ["a"]) in client.tagged
assert ("id:_pipeline/processed", ["a"]) in client.tagged
s.close()
def test_apply_all_reports_per_cluster(tmp_path):
s = _store(tmp_path)
_approved(s, name="Venice")
client = FakeImmich()
results = apply_all(client, s)
assert len(results) == 1 and results[0]["status"] == "approved"
s.close()
def test_apply_skipped(tmp_path):
s = _store(tmp_path)
cid = s.insert_cluster(
Cluster(start_at="2019-06-01", end_at="2019-06-02", status="skipped"),
[ClusterMember(cluster_id=0, immich_id="a"),
ClusterMember(cluster_id=0, immich_id="b")])
client = FakeImmich()
res = apply_cluster(client, s, cid)
# skipped: only _pipeline/processed is written, no content/non-trip tag
assert res["status"] == "skipped"
assert res["succeeded"] == [] and res["failed"] == []
assert ("id:_pipeline/processed", ["a", "b"]) in client.tagged
assert len(client.tagged) == 1 # nothing but the processed tag
assert s.get_asset("a").processed is True
assert s.get_asset("b").processed is True
s.close()
def test_upsert_tag_failure_is_caught_and_does_not_abort_batch(tmp_path):
# A failing upsert_tag must be recorded as a per-asset failure (retryable),
# not propagate out of apply_cluster and abort apply_all (review finding R1).
s = _store(tmp_path)
_approved(s, name="Venice")
_approved(s, name="Rome") # second cluster must still be applied
client = FakeImmich()
client.fail_upsert = "Venice" # first cluster's trip-tag upsert fails
results = apply_all(client, s)
assert len(results) == 2 # batch was not aborted by the first failure
venice = next(r for r in results if r["cluster_id"] == 1)
assert venice["succeeded"] == [] and sorted(i for i, _ in venice["failed"]) == ["a", "b"]
assert s.already_applied("a", "trip", "Venice") is False # retryable
rome = next(r for r in results if r["cluster_id"] == 2)
assert sorted(rome["succeeded"]) == ["a", "b"]
s.close()
def test_processed_write_failure_is_surfaced_in_result(tmp_path):
# Trip tag succeeds but the _pipeline/processed write fails: the failure must
# appear in result["failed"] and the asset must NOT be marked processed.
s = _store(tmp_path)
cid = _approved(s, name="Venice")
client = FakeImmich()
client.fail_upsert = pipeline.PROCESSED
res = apply_cluster(client, s, cid)
assert sorted(res["succeeded"]) == ["a", "b"] # trip tag still applied
assert sorted(i for i, _ in res["failed"]) == ["a", "b"] # processed surfaced
assert s.get_asset("a").processed is False
s.close()
+60
View File
@@ -0,0 +1,60 @@
import io
import os
import threading
import time
import pytest
from PIL import Image
from werkzeug.serving import make_server
from app import create_app
from app.config import Config
from photoflow.core import Store
from photoflow.core.models import Asset, Cluster, ClusterMember
def _jpeg(color):
buf = io.BytesIO()
Image.new("RGB", (48, 48), color).save(buf, format="JPEG")
return buf.getvalue()
@pytest.fixture(scope="session")
def data_dir(tmp_path_factory):
d = str(tmp_path_factory.mktemp("data"))
thumbs = os.path.join(d, "thumbs")
os.makedirs(thumbs, exist_ok=True)
s = Store(os.path.join(d, "trip-cluster.db")).connect()
palette = {"a": (10, 20, 30), "b": (40, 80, 120), "c": (200, 50, 90)}
for i, t in [("a", "2019-06-01"), ("b", "2019-06-02"), ("c", "2019-07-10")]:
s.upsert_asset(Asset(immich_id=i, taken_at=t))
with open(os.path.join(thumbs, f"{i}.jpg"), "wb") as f:
f.write(_jpeg(palette[i]))
s.insert_cluster(
Cluster(start_at="2019-06-01", end_at="2019-06-02", suggested_name="Venice",
confidence=0.9, kind_guess="trip", status="pending"),
[ClusterMember(cluster_id=0, immich_id="a"),
ClusterMember(cluster_id=0, immich_id="b")])
s.insert_cluster(
Cluster(start_at="2019-07-10", end_at="2019-07-10", suggested_name="Rome",
confidence=0.3, kind_guess="trip", status="pending"),
[ClusterMember(cluster_id=0, immich_id="c")])
s.close()
return d
@pytest.fixture(scope="session")
def flask_app(data_dir):
cfg = Config(immich_url="http://127.0.0.1:1", immich_api_key="k",
anthropic_api_key="", data_dir=data_dir)
return create_app(cfg)
@pytest.fixture(scope="session")
def base_url(flask_app):
server = make_server("127.0.0.1", 8095, flask_app)
t = threading.Thread(target=server.serve_forever, daemon=True)
t.start()
time.sleep(0.2)
yield "http://127.0.0.1:8095"
server.shutdown()
@@ -0,0 +1,45 @@
def _open(page, base_url):
page.goto(base_url)
page.wait_for_selector(".photo-card")
def test_grid_arrow_navigation_moves_focus_ring(base_url, page):
# Venice (2 photos) is high-confidence; click it to load its detail.
_open(page, base_url)
page.click("text=Venice")
page.wait_for_selector(".photo-card")
cards = page.query_selector_all(".photo-card")
assert len(cards) == 2
page.keyboard.press("ArrowRight")
# second card gains the focus ring class
assert "ring-4" in (page.query_selector_all(".photo-card")[1].get_attribute("class"))
def test_lightbox_opens_and_closes(base_url, page):
_open(page, base_url)
page.click("text=Venice")
page.wait_for_selector(".photo-card")
page.keyboard.press("ArrowRight")
page.keyboard.press("Enter")
assert page.is_visible("#lb")
page.keyboard.press("Escape")
page.wait_for_selector("#lb", state="hidden")
def test_cluster_switching_with_brackets(base_url, page):
_open(page, base_url)
page.click("text=Venice")
page.wait_for_selector(".photo-card")
page.keyboard.press("]") # move to the next cluster
page.wait_for_function(
"document.querySelectorAll('.photo-card').length === 1") # Rome has 1 photo
def test_approve_updates_status(base_url, page):
_open(page, base_url)
page.click("text=Venice")
page.wait_for_selector("#cluster-name")
page.once("dialog", lambda d: d.accept()) # no dialog expected, but be safe
page.click("text=Approve (A)")
page.wait_for_selector("text=approved")
assert "approved" in page.inner_text("body")
@@ -0,0 +1,9 @@
def test_health(base_url, page):
page.goto(f"{base_url}/health")
assert "ok" in page.content()
def test_index_lists_clusters(base_url, page):
page.goto(base_url)
assert "Venice" in page.inner_text("body")
assert "Rome" in page.inner_text("body")
+13
View File
@@ -0,0 +1,13 @@
services:
trip-cluster:
build:
context: .
dockerfile: apps/trip-cluster/Dockerfile
ports:
- "8084:8084"
volumes:
- ./data:/data
env_file: .env
environment:
- DATA_DIR=/data
user: "${UID}:${GID}"
+25
View File
@@ -0,0 +1,25 @@
# Backlog
Small, near-term open tasks that don't warrant a full milestone. Milestones live in
`docs/ROADMAP.md`; this file tracks the loose ends between them. Remove an item when it's done.
## Open
- [ ] **pgvector spike — final coverage snapshot.** The feasibility spike has *passed* (embeddings
readable, `assetId → asset.id` join clean, shape pinned: `smart_search.embedding`, 1152-dim,
cosine `<=>`). The CLIP re-run with the stronger model is still in progress (coverage ~70% and
climbing as of 2026-06-27). When it reaches ~100%, run `scripts/pgvector_spike.py` once (no
flags) and commit the refreshed findings doc to record final coverage.
See the "Definition of done" in `docs/superpowers/specs/2026-06-27-pgvector-embedding-spike-design.md`.
- [ ] **M1 validation gate — run on a hard, GPS-poor sample.** M1 shipped, but the acceptance-bar
run on a hard sample (not the easy last trip) is still pending before widening.
See `docs/M1-validation-gate.md` and the M1 row in `docs/ROADMAP.md`.
## Later / dependent
- [ ] **M1.5 — provision a least-privilege read-only Postgres role.** When M1.5 builds the pgvector
reader, replace the spike's session-level read-only guard (using Immich's write-capable `postgres`
user) with a dedicated `SELECT`-only role. Recipe is in the findings doc. Also: the pgvector reader
belongs in `shared/photoflow/immich` (the only Immich client) and any SQLite access in
`shared/photoflow/core` (the only SQLite owner) — not in app-level code.
+26
View File
@@ -0,0 +1,26 @@
# M1 Validation Gate
Before widening past the hard sample, trip-cluster must clear a quantified bar on a
**deliberately hard slice** — a GPS-poor, multi-year, low-density window (a well-remembered
old trip plus its surrounding everyday photos), **not** the easy phone-era last trip.
## Protocol
1. `categorize ingest --from <hard-window-start> --to <hard-window-end>` (or `--tag <old-trip>`).
2. `categorize cluster`, then `categorize serve`.
3. Hand-label the slice: the true trip boundaries + which surrounding photos are non-trip.
4. Compare candidate clusters against the labels.
## Acceptance bar (record actual numbers per run)
| Metric | Definition | Target |
|--------|------------|--------|
| Trip-boundary precision | proposed boundaries that are real | ≥ 0.8 |
| Trip-boundary recall | real boundaries proposed | ≥ 0.8 |
| Coverage-flag recall | in-window missing-tag assets surfaced | ≥ 0.9 |
| Over-split rate | extra clusters per real trip | ≤ 0.5 |
| False-cluster rate | clusters that are pure noise | ≤ 0.1 |
If unmet, tune `--gap-factor` / seed-span / confidence thresholds — do **not** widen the
backlog. See the F4 open question (everyday cluster blow-up): observe the real cluster
count on a representative subset first, then choose the surfacing/collapsing strategy.
+6 -2
View File
@@ -31,7 +31,8 @@ Each app is independently containerized (its own Dockerfile + port). `shared/*`
| # | Name | Goal | Status |
|---|------|------|--------|
| **M1** | Foundation + `trip-cluster` | Stand up the monorepo, shared packages, SQLite store, Immich ingest, and the `trip-cluster` app end-to-end. Validate on the last trip's photos. **This is the POC that proves the foundation.** | Design approved; spec written; plan pending |
| **M1** | Foundation + `trip-cluster` | Stand up the monorepo, shared packages (`shared/ai` deferred to M3), SQLite store, Immich ingest, and the `trip-cluster` app end-to-end. Validate on a **hard, GPS-poor sample against a quantified acceptance bar** (not the easy last trip) before widening. **This is the POC that proves the foundation.** | **Shipped** — implemented, reviewed, merged to `main` (2026-06-27); 66 tests green. Validation-gate run on a hard sample still pending. |
| **M1.5** | Visual similarity | Trip-level CLIP clustering over Immich's pgvector embeddings (readability/shape verified by the pgvector spike — see findings) — the rescue signal for the GPS-poor old library where timestamp/GPS signals are weakest. | Not started; dependency spike **done** (2026-06-27, embeddings readable + joinable — see findings spec) |
| **M2** | `tag-verify` | Verify/normalize existing tags, dedupe the tag vocabulary, find outliers — on the proven foundation. | Not started |
| **M3** | `enrich` | Geocode existing location tags ("Kiev" → coordinates) and backfill GPS into Immich to improve future trip detection; AI captioning; content tags; noise detection. | Not started |
| **M4** | Migrate `image-rater` | Move the existing app onto the shared packages (+ optional SQLite). Largely mechanical (delete local copies, import shared). | Not started |
@@ -57,9 +58,12 @@ These apply across all milestones (decided during the 2026-06-27 brainstorm):
## Enablement notes (things to set up to unlock later milestones)
- **Visual similarity (post-M1, leading enhancement):** Immich's REST API doesn't cleanly expose CLIP embeddings. Viable path = **read-only access to Immich's Postgres pgvector** embeddings (verify table/column against the live version). User can provide DB access; user is re-running CLIP with a stronger model — both pure upside for trip-level similarity, especially valuable for the GPS-poor old library.
- **Visual similarity (M1.5; dependency verified by the pgvector spike, 2026-06-27):** Immich's REST API doesn't cleanly expose CLIP embeddings. Viable path = **read-only access to Immich's Postgres pgvector** embeddings. A read-only feasibility spike (`scripts/pgvector_spike.py`, tracked separately from M1 — *not* run inside M1) **confirmed** embeddings are readable and pinned the shape against the live DB: `smart_search.embedding`, 1152-dim, cosine `<=>`, `assetId → asset.id` join clean. See the findings spec for the version-pinned contract. The trip-level clustering itself is **M1.5**. User provides DB access (`IMMICH_DB_URL`) and is re-running CLIP with a stronger model — both pure upside, especially for the GPS-poor old library.
- **GPS facts:** Immich's reverse-geocoding only *labels* coordinates a photo already has; it does **not** invent GPS, and Immich cannot infer GPS from image content. For GPS-less old photos, coordinates come from manual map placement or the **M3 `enrich`** step (geocode location tags → write back as GPS). Reverse-geocoding + metadata-extraction jobs can be run anytime to strengthen location anchors for the GPS-having subset.
## Related specs
- Open near-term tasks (loose ends between milestones): `docs/BACKLOG.md`
- M1: `docs/superpowers/specs/2026-06-27-immich-photo-flow-design.md`
- M1.5 pgvector spike — design: `docs/superpowers/specs/2026-06-27-pgvector-embedding-spike-design.md`
- M1.5 pgvector spike — findings (M1.5's contract): `docs/superpowers/specs/2026-06-27-pgvector-embedding-findings.md`
@@ -0,0 +1,71 @@
---
title: Creating pull requests on the self-hosted Gitea remote
date: 2026-06-27
category: tooling-decisions
module: dev-workflow / gitea
problem_type: tooling_decision
component: development_workflow
severity: low
applies_when:
- Opening or editing a PR for this repo from a non-interactive agent/CI shell
- "`gh pr create` fails with: none of the git remotes point to a known GitHub host"
- tea exits with "Failed to read SSH passphrase ... could not open TTY"
tags: [gitea, pull-request, tea, gh, rest-api, ssh, dotenv]
---
# Creating pull requests on the self-hosted Gitea remote
## Context
This repo's `origin` is a **self-hosted Gitea** instance, not GitHub:
- API/web base: `https://git.gorinskat.nl` (owner `m038`, repo `immich-photo-flow`)
- Push remote (SSH): `ssh://git@m038-nas.tail63ee39.ts.net:222/m038/immich-photo-flow.git` — a *different host* than the API, reverse-proxied behind nginx.
Opening a PR from an agent/non-interactive shell fails through the two obvious tools, which wastes a lot of back-and-forth if you don't know why.
## Guidance
Open PRs via the **Gitea REST API over HTTPS**, authenticated with a token from `.env`. Do not rely on `gh` or `tea` from a non-interactive shell.
1. Make sure the **base branch already exists on the server** — a PR needs it. (Gitea sets the repo default branch to the *first* branch you push, so push your base branch, e.g. `main`/`master`, before or alongside the feature branch.)
2. Read `GITEA_HOST`, `GITEA_USER`, `GITEA_TOKEN` from `.env` (a `write:repository`-scoped token). **Never print, echo, or commit `.env`**`source` it; keep the token out of `argv` (use a `curl -K` config file, mode 0600) and shred the file after.
3. `POST {base}/api/v1/repos/{GITEA_USER}/{repo}/pulls` with `{head, base, title, body}`.
```bash
set -a; . .env; set +a # never cat/echo this file
case "$GITEA_HOST" in *://*) base="$GITEA_HOST";; *) base="https://$GITEA_HOST";; esac
umask 077; cfg=$(mktemp); printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$cfg"
# body via file -> JSON payload (avoids backtick/$ re-evaluation in the body)
python3 - <<'PY'
import json; json.dump({"head":"feat/my-branch","base":"main",
"title":"feat: ...","body":open("/path/to/body.md").read()}, open("/tmp/pr.json","w"))
PY
curl -sS -K "$cfg" -X POST -H "Content-Type: application/json" --data @/tmp/pr.json \
"${base%/}/api/v1/repos/$GITEA_USER/$repo/pulls" -w '\nHTTP %{http_code}\n'
shred -u "$cfg"
```
Repo-level settings use the same API, e.g. set the default branch:
`PATCH {base}/api/v1/repos/{owner}/{repo}` with `{"default_branch":"main"}` (only do this with explicit user consent — it's a persistent change to shared infra).
## Why This Matters
- **`gh` is GitHub-only.** It errors `none of the git remotes ... point to a known GitHub host` and cannot target a Gitea instance.
- **`tea` (0.14) can't authenticate non-interactively here.** Its login is configured with an SSH key that has a passphrase, and it insists on reading the passphrase from `/dev/tty` — even with `ssh_agent: true` and the key already loaded in the agent. From an agent/CI shell there is no TTY, so it dies with `could not open TTY`. (Plain `git`-over-SSH still works because the agent answers the key; only `tea`'s own auth flow needs the TTY.) The user *can* run `tea` in their own terminal — it only fails for non-interactive callers.
- The REST API needs neither GitHub nor a TTY, so it's the reliable path for automation.
## When to Apply
- Any time an agent needs to open or edit a PR (or change repo settings) on this Gitea remote.
- Generalizes to any non-GitHub Gitea/Forgejo remote reached from a non-interactive shell.
## Examples
- **Symptom → cause:** `gh pr create` → wrong forge; `tea pulls create``Failed to read SSH passphrase: could not open TTY` → use the REST API instead.
- **Gotcha:** after pushing only a feature branch first, the repo default branch became the feature branch; pushing `master` gave the PR a base, and a later `PATCH default_branch` fixed the default.
## Related
- Credentials live in `.env` (gitignored): `GITEA_HOST`, `GITEA_USER`, `GITEA_TOKEN` — documented in `.env.example`.
- The two repo paths `~/Projects/immich-photo-flow` and `~/Nextcloud/Projects/immich-photo-flow` are the same repo (`~/Projects` is a symlink), not two clones.
File diff suppressed because it is too large Load Diff
@@ -51,7 +51,7 @@ Both apps currently **duplicate** near-identical code (`immich.py`, `config.py`,
## Roadmap
The full multi-milestone roadmap (M1M6), end-state, and cross-cutting decisions live in the authoritative **[`docs/ROADMAP.md`](../../ROADMAP.md)**. In brief: **this spec is M1** — Foundation + `trip-cluster`, the POC that proves the shared foundation. M2 (`tag-verify`), M3 (`enrich`), M4/M5 (migrate the existing apps onto the foundation), and M6 (non-trip categorization) follow, each with its own spec→plan→build cycle.
The full multi-milestone roadmap (M1M6), end-state, and cross-cutting decisions live in the authoritative **[`docs/ROADMAP.md`](../../ROADMAP.md)**. In brief: **this spec is M1** — Foundation + `trip-cluster`, the POC that proves the shared foundation. M1.5 (visual-similarity clustering, dependency spiked in M1), M2 (`tag-verify`), M3 (`enrich`, which also introduces `shared/ai`), M4/M5 (migrate the existing apps onto the foundation), and M6 (non-trip categorization) follow, each with its own spec→plan→build cycle.
---
@@ -69,8 +69,8 @@ immich-photo-flow/
shared/
immich/ # the one true Immich client
core/ # SQLite store + domain models + persistence
ai/ # Anthropic batch wrapper (scaffolded; light/unused in M1)
ui/ # base.html, Tailwind/DaisyUI/Alpine/HTMX, Jinja macros, app.js
# (shared/ai is deferred to M3 — see Shared packages)
apps/
trip-cluster/
Dockerfile
@@ -108,8 +108,8 @@ SQLite store + domain dataclasses + persistence (connection, schema/migrations,
**SQLite tables:** `assets`, `asset_tags`, `tags`, `clusters`, `cluster_members`, `writeback_log`, `meta`. Single DB file on the mounted volume.
### `shared/ai`
Anthropic **batch** Messages API wrapper using the official `anthropic` SDK, carrying over image-rater's proven conventions (per-criterion judgments, `confidence`, score floors, chunking ≤50/batch, default model `claude-haiku-4-5`). **Scaffolded but barely used in M1** — trip-cluster is algorithm-first (near-zero AI cost). It earns its keep in M3 (enrich).
### `shared/ai` — deferred to M3
**Not built in M1.** trip-cluster is algorithm-first (near-zero AI cost), so M1 has no consumer for an Anthropic wrapper; building it now would freeze its interface before the M3 enrich requirements that actually shape it. `shared/ai` is introduced in **M3 (enrich)** — an Anthropic **batch** Messages API wrapper using the official `anthropic` SDK, carrying over image-rater's proven conventions (per-criterion judgments, `confidence`, score floors, chunking ≤50/batch, default model `claude-haiku-4-5`). The `ANTHROPIC_API_KEY` plumbing stays optional in M1.
### `shared/ui`
The shared visual foundation, extracted from the proven travel-memories/image-rater templates:
@@ -141,28 +141,36 @@ Pull assets from Immich via `shared/immich`, upsert metadata into SQLite, downlo
- `--tag NAME` → a single already-tagged trip;
- `--subset N` → a cap.
This lets the whole tool be validated on the last trip for near-zero cost before widening to the backlog over time.
This lets the tool be validated cheaply before widening to the backlog over time. **Validation gate (M1):** validate on a deliberately *hard* sample — a GPS-poor, multi-year, low-density slice (e.g. a well-remembered old trip plus its surrounding everyday photos), **not** the most recent trip (the easy case: phone-era, GPS-rich, already tagged). Widening is gated on a **quantified acceptance bar** measured against a small hand-labelled set: trip-boundary precision/recall, coverage-flag recall, and an acceptable over-split / false-cluster rate. Until that bar is met on the hard sample, the backlog is not widened.
### 2. Cluster (automatic) — signal hierarchy
Pure, algorithmic, **no API calls** (keeps the POC nearly free). Produces candidate trips, each with a `suggested_name`, `confidence`, and `kind_guess`:
1. **Existing trip tag** → authoritative seed; its assets form a confirmed cluster (still shown, to verify *completeness*). Respects the user's existing trip-tag convention.
2. **Timestamp gap clustering** → primary structure: sort by `taken_at`, split where the inter-photo gap exceeds a tunable threshold.
2. **Timestamp gap clustering** → primary structure: sort by `taken_at`, split where the inter-photo gap is large relative to local cadence. A single global threshold fails across a 15-year density gradient (a sparse old trip has multi-day intra-trip gaps; a dense recent everyday period has hour-scale inter-day gaps), so the split is **density-adaptive** — threshold relative to local photo cadence / a per-era percentile — validated with unit fixtures spanning both a sparse-old and a dense-recent regime.
3. **Location anchors** (existing location tags like "Kiev" + GPS when present) → refine boundaries, propose names.
4. **Coverage detection** → assets *inside* a confirmed trip's time window but *missing* its tag are flagged "likely belongs here" (the completeness gap); assets carrying a trip tag but *outside* their cluster are flagged as outliers.
5. **Visual similarity****deferred from M1; leading post-M1 enhancement.** Trips are defined by time + place, not visual likeness, so the signals above resolve the large majority; visual similarity only helps a narrow case (an ambiguous time gap where two bursts may be one trip). pHash is the wrong tool (it finds near-duplicates, not trip-level similarity). CLIP is the right tool, but Immich's REST API does not cleanly expose raw embedding vectors. **Viable path: read-only access to Immich's Postgres pgvector embeddings** (do nearest-neighbor/clustering ourselves), pending a feasibility check against the live Immich version. Especially valuable here because the old library is GPS-poor, so visual continuity may be one of the few secondary signals for those photos.
5. **Visual similarity****clustering deferred to M1.5; dependency verified in M1.** Trips are defined by time + place, not visual likeness, so the signals above resolve the large majority; visual similarity helps the narrow-but-important case of the GPS-poor old library, where visual continuity may be one of the few secondary signals. pHash is the wrong tool (near-duplicates, not trip-level similarity); CLIP is right, but Immich's REST API does not cleanly expose raw embedding vectors. **Viable path: read-only access to Immich's Postgres pgvector embeddings** (do nearest-neighbor/clustering ourselves). Because this is the rescue signal for the hardest case, **M1 includes a read-only feasibility spike** — confirm the embeddings are readable and pin the table/column shape and DB access/credential against the live Immich version (no clustering build). The clustering itself is **M1.5** (see roadmap), so it sits in a scheduled near-term milestone rather than floating indefinitely.
**Confidence & kind_guess** (echoing image-rater's confidence/floor approach): tight time window + existing trip tag + consistent location → high confidence; sparse, untagged, no GPS → low ("needs your eye"). Low-volume scattered clusters → `kind_guess = everyday` (suggested non-trip).
### 3. Review (human, cluster-level)
A review screen lists **candidate trips sorted by "needs attention"** (low confidence first). Per cluster: thumbnail grid (with the shared grid+lightbox / arrow-key / full-screen component), editable suggested name, and actions:
**Layout — master/detail.** A left rail lists **candidate clusters sorted by "needs attention"** (low confidence first), each with confidence / status / count badges; selecting one opens a detail pane with its editable suggested name, action controls, and the canonical **grid + lightbox** (ring-selection, arrow-key navigation *within the grid*, full-screen view, Esc to close). Moving between clusters uses its own shortcut so arrow keys stay bound to the grid.
Per-cluster actions:
- **Approve trip** (confirm + tweak name)
- **Mark non-trip**
- **Split** (break one cluster into two)
- **Merge adjacent** (combine with a neighbor)
- **Split** — select a boundary asset in the chronologically-ordered grid and choose "split before here"; the cluster partitions at that timestamp into two clusters, both with editable names. Reuses the existing ring-selection / arrow-key focus.
- **Merge** — surfaces the cluster's **chronological** neighbour(s) (prev/next by time, computed from a temporal index, *independent of the needs-attention list sort*) with a preview of the combined range; the user confirms which to absorb.
- **Skip**
To keep it fast: high-confidence clusters arrive **pre-filled**, and an **"approve all high-confidence"** bulk action lets the user rubber-stamp the obvious cases, concentrating attention on the fuzzy ones. Every decision **persists immediately** to SQLite (resumable: closing and reopening resumes exactly where the user left off).
**Per-asset refinement (coverage flags).** Within a cluster's grid, assets flagged "likely belongs here" (the completeness gap) appear with a distinct badge and an **include** toggle; flagged **outliers** appear with an **exclude** toggle. Include/exclude updates `cluster_members` before write-back, so the algorithm's completeness work is actionable rather than informational.
**Keyboard-first.** Cluster-level actions mirror the sibling convention (e.g. `A` approve, `N` non-trip, `S` split, `M` merge, `X` skip; arrows navigate within the grid; `[` / `]` move between clusters), listed in a help footer as image-rater/travel-memories do. *(First-class requirement.)*
**Empty states.** Because ingest is scopeable and assets can already be `_pipeline/processed`, a `cluster` run can legitimately yield nothing. `serve` distinguishes three cases, each naming the next step: *no clusters yet* (run ingest/cluster), *all clusters reviewed*, and *this scope produced no clusters*.
To keep it fast: high-confidence clusters arrive **pre-filled**, and an **"approve all high-confidence"** bulk action lets the user rubber-stamp the obvious cases, concentrating attention on the fuzzy ones. Every decision **persists immediately** to SQLite, so review is resumable across sessions (see Write-back for what survives loss of the SQLite layer itself).
### 4. Write-back (to Immich, idempotent)
On approval (per-cluster or batch `apply`):
@@ -170,8 +178,9 @@ On approval (per-cluster or batch `apply`):
- **Mark non-trip** → apply `_pipeline/non-trip` so those assets are filtered out and never resurface as unreviewed.
- **Mark processed** → every adjudicated asset (trip-assigned, non-trip, **or** reviewed-and-skipped) gets `_pipeline/processed`. This is the durable "done" flag in the source of truth: it captures the reviewed-but-untagged case, and lets a fresh `ingest` re-derive what's already handled even if the SQLite working DB is lost (see Ingest read-back).
- **Idempotent**: `writeback_log` records applied changes; re-runs skip what's already done. **Explicit confirmation before any write** (mirrors image-rater's export safety).
- **Partial-failure reporting**: a batch "apply all approved" reports **per-cluster results** (succeeded / failed with reason) from `writeback_log`; clusters that fail stay in their approved-but-unapplied state so a re-run retries only those. The UI surfaces this as inline status badges plus a summary, so the user always knows what landed in the library.
All durable state lands in **Immich**; SQLite remains the working/review layer that can be rebuilt from Immich tags.
All durable state lands in **Immich**; SQLite is the working/review layer. **Only applied (tag-written) decisions are rebuildable from Immich** — approved-but-unapplied clusters, in-progress split/merge, edited names and notes live only in SQLite. So the resumability guarantee holds against loss of SQLite only for applied work: decisions should be **applied promptly on approval** (or the SQLite working DB backed up), and the rebuild-from-Immich path recovers everything already written as tags.
### Tag conventions (shared across all apps)
Two clearly separated kinds of tags:
@@ -206,3 +215,11 @@ M1 is algorithm-first, so trip-cluster makes **essentially no Anthropic calls**
- Migrating the existing apps onto the shared foundation (M4/M5).
- Geocoding / GPS backfill / AI captioning (M3).
- Narrative text drafting (human-owned; downstream).
---
## Deferred / Open Questions
### From 2026-06-27 ce-doc-review
- **Cluster-count blow-up from everyday photos (F4).** Cluster-level QA assumes "a few dozen" candidate trips, but clustering everyday/non-trip photos with per-gap splitting could mint hundredsthousands of low-confidence clusters that "approve all high-confidence" won't relieve. The concern is real, but the reviewer's proposed pre-pass (collapsing everyday spans into bulk buckets) is unconvincing; a candidate alternative is ordering/filtering clusters by **date-span** (a multi-week span ranks above a 2-day span). Crucially, the actual cluster output on real Immich data is unknown to both reviewer and author — resolve by **observing real behaviour on a representative subset first**, then choose the surfacing/collapsing strategy.
@@ -0,0 +1,84 @@
# pgvector embedding feasibility spike — findings
**Generated:** 2026-06-27 18:49 UTC by `scripts/pgvector_spike.py`
**Status:** machine-probed against the live Immich Postgres.
**This is M1.5's contract.** See the design spec:
`docs/superpowers/specs/2026-06-27-pgvector-embedding-spike-design.md`.
> ⚠️ **Immich's undocumented, internal schema — no deprecation contract.**
> Everything below is valid **only** for the model + Immich version observed and
> can be renamed/restructured on any Immich upgrade. M1.5 must re-run this probe
> (or version-guard) on every Immich upgrade. The "contract" is version-pinned,
> not durable.
## Requirement 0 — does M1.5 need raw vectors (vs. a REST query)?
M1.5 clusters photos by *visual similarity* at trip level — it needs either the
raw CLIP embedding vectors or an arbitrary asset→asset nearest-neighbour query.
Immich's REST surface provides neither:
- `POST /api/search/smart`**text→image** CLIP search: takes a text query,
returns assets. It never returns embedding vectors and cannot do asset→asset
similarity without a text prompt. Insufficient.
- `POST /api/search/metadata`, `/api/search/random` — metadata/random only; no
embeddings, no similarity.
- Duplicate detection (`/api/duplicates`) consumes embeddings *internally* but only
surfaces near-duplicate groups above Immich's own threshold — not a tunable
pairwise similarity usable for trip-level clustering. Insufficient.
- No documented endpoint returns raw CLIP vectors or arbitrary k-NN neighbours.
**Conclusion:** the load-bearing "REST can't expose embeddings" premise holds for
Immich's documented API → **read-only Postgres access (below) is the viable path.**
(Re-confirm against the OpenAPI of the running version on upgrade.)
## Requirements 14 — probed facts
| # | Question | Answer |
|---|----------|--------|
| 1 | Embeddings readable? | **yes — read 20727 distinct embedding(s)** |
| 2 | Join to asset table? | **20727/20727 embeddings join to asset via assetId** |
| 3 | Table / column | `public.smart_search` / `embedding` |
| 3 | Vector dimension | **1152** (live sample=1152, declared typmod=1152) |
| 3 | Distance operator | **<=> (cosine) — from index opclass vector_cosine_ops** |
| 3 | Operator sanity check | OK — nearest neighbour (a duplicate (identical-vector) asset) at distance 0 (seed self-distance 0) |
| 4 | Coverage (raw) | **45.2%** (20727/45854 assets) |
| 4 | Coverage (image-only) | **46.6%** (20323/43601 IMAGE assets) |
- **Asset-key join:** FK smart_search.assetId -> asset.id
- **Orphan embeddings** (no matching asset): 0
- **SQLite cross-check (optional):** skipped — no SQLite store at data/trip-cluster.db (fresh checkout; not a failure)
## The join M1.5 relies on
`smart_search.assetId``asset.id`
SQLite `assets.immich_id` (`shared/photoflow/core`). The Immich asset UUID is the
same key our store uses, so embeddings index straight onto tracked photos.
> ⚠️ Immich's asset table is **`asset`** in the observed version
> (it was `assets` in older versions). The probe discovers this from the FK; M1.5's
> reader must not hardcode the name.
## Environment observed
- **Postgres:** 18.2 (Debian 18.2-1.pgdg12+1)
- **pgvector extension:** 0.8.1
- **Schema age proxy (latest migration):** unknown
- **Immich server version:** _record from the Immich UI / `GET /api/server/version`_
— not reliably available in the DB.
- **CLIP model:** _record the model from Immich's Machine-Learning settings_ — the
user is re-running CLIP with a stronger model, so dimension + coverage above are a
snapshot of whichever model was live at probe time.
## Read-only access (recommended hardening for M1.5)
The probe enforces read-only at the session layer
(`SET default_transaction_read_only = on` + psycopg `read_only`), which neutralises
write capability even with Immich's write/DDL-capable `postgres` user. For M1.5,
provision a dedicated least-privilege role instead:
```sql
CREATE ROLE photoflow_ro LOGIN PASSWORD '...';
GRANT CONNECT ON DATABASE immich TO photoflow_ro;
GRANT USAGE ON SCHEMA public TO photoflow_ro;
GRANT SELECT ON public.smart_search, public.asset TO photoflow_ro;
```
@@ -0,0 +1,202 @@
# pgvector embedding feasibility spike — design
**Date:** 2026-06-27
**Milestone dependency:** unblocks **M1.5** (visual similarity). See `docs/ROADMAP.md`.
**Status:** passed 2026-06-27 — Req 04 answered (see findings doc). Final coverage snapshot pending CLIP re-run completion; see "Running the spike" / "Definition of done" below.
## Why this exists
The roadmap narrative claims M1 "runs a read-only feasibility spike" and that M1.5's
dependency is "verified in M1." It is not: the M1 plan explicitly deferred the spike
(`docs/superpowers/plans/2026-06-27-immich-photo-flow-m1.md:3710` — *"pgvector spike →
out of this plan by decision (tracked separately; M1.5 dependency)"*). So M1.5 is blocked
on a prerequisite that never ran. This spec defines that prerequisite.
M1.5 wants to cluster photos by visual similarity using the CLIP embeddings Immich already
computes — the rescue signal for the GPS-poor old library where timestamp/GPS are weakest.
The working assumption is that Immich's REST API does not cleanly expose raw embedding
vectors, so the viable path is **read-only access to Immich's Postgres pgvector embeddings**.
That assumption is load-bearing — it must be *tested*, not asserted (see Requirement 0). Before
M1.5 can build clustering on that path, this spike must prove the path exists and pin its shape.
## Scope
**Feasibility only.** Read-only. Throwaway probe. The spike:
- does **not** build clustering, a tested module, or any `shared/` reader;
- does **not** write to Immich or its database (only `SELECT`);
- exists to turn unknowns into **pinned facts** and hand M1.5 a verified contract.
## Requirements (pass/fail)
The spike succeeds when it answers all of the following against the **live** database and
records the answers in the findings doc. Phrased as plain questions:
0. **Does M1.5 actually need raw vectors (vs. a REST similarity query)?** Before relying on
DB access, state what M1.5's clustering needs — raw embedding vectors or a pairwise
similarity-neighbor query — and record which Immich REST endpoints were checked (e.g.
`/api/search/smart`, any asset-similarity/duplicate endpoint) and *why each is
insufficient*. This turns the load-bearing "REST can't expose embeddings" premise into a
documented finding; if a REST path suffices, the DB-access path below is unnecessary.
1. **Can we read the CLIP embeddings at all?** Connect read-only over the LAN and read the
embedding vectors Immich stores. *This de-risks **access** only: if the embeddings are not
readable, M1.5 needs a different plan. It does **not** prove the signal is useful (see the
signal-usefulness note below).*
2. **Can we join each embedding back to a photo we already track?** Confirm **inside
Postgres** that each embedding row's key references `assets.id` — the join that must hold
for the vectors to be usable. That asset ID is the same key our SQLite store uses, so a
cross-check against SQLite (`shared/photoflow/core`) is *optional* confirmation and must
degrade gracefully when the store is unpopulated (a fresh checkout has no `ingest` run),
rather than failing this requirement for the wrong reason.
3. **What are the exact shapes?** Record the table name, embedding column, **vector
dimension**, and the correct pgvector **distance operator** for similarity. These are
**model- and version-dependent** (and have drifted across Immich versions), so they must be
read from *this* DB, not assumed — and the recorded shape is valid only for the model +
Immich version observed (see Requirement 4 / Risks).
4. **What is the coverage?** What fraction of the **embeddable** library currently has an
embedding. Compute against the image/embeddable population, not all assets —
`count(distinct embedding.assetId) / count(assets WHERE type = 'IMAGE')` (or Immich's
equivalent asset-type filter) — since videos and other non-image rows CLIP never embeds
would otherwise deflate the ratio. Record both the raw and image-only ratios. *Tells M1.5
how much it can lean on the signal — but the user is re-running CLIP with a stronger model,
so coverage (and the dimension in Requirement 3) is a snapshot of whichever model is live
when the probe runs.*
**Signal-usefulness is M1.5's first task, not this spike's.** Passing Requirements 14 proves
the embeddings are reachable and well-shaped; it does **not** prove visual similarity actually
rescues trip detection in the GPS-poor library. Before building clustering, M1.5 must validate
the signal (e.g. eyeball nearest-neighbour quality on a sample of the GPS-poor set).
## What we already know (to verify, not assume)
Immich historically stores CLIP vectors in a **`smart_search`** table with an **`embedding`**
column of pgvector type `vector`, keyed by `assetId` referencing `assets.id`; similarity uses
**cosine** distance (`<=>`); the legacy default model (`ViT-B-32`) produced **512-dim**
vectors. Names and dimension have changed across versions and the user's re-run uses a
stronger model, so the probe **discovers** these rather than trusting them — the list above is
only the set of candidates to probe first.
This is Immich's **undocumented, internal** schema with no deprecation contract: it can be
renamed or restructured on any Immich upgrade. The probe therefore records the exact Immich
version observed alongside the shape, and M1.5 must budget a re-probe / version-guard on every
Immich upgrade — the "contract" the findings doc hands M1.5 is version-pinned, not durable.
## Approach
Disposable read-only probe + two durable artifacts (chosen over a throwaway-only or a
build-the-module-now approach: feasibility-only honors the roadmap, but capturing the DSN and
a written contract is the cheap part that saves M1.5 from guessing).
### Connection
- New optional env var **`IMMICH_DB_URL`** (a Postgres DSN), added to `.env.example` and
loaded by `config.py` as an **optional** field (REST creds stay required; the DSN is only
needed for the spike / M1.5). `config.py` reads only `os.environ`, and the repo loads `.env`
solely via docker-compose's `env_file`, so a standalone host-run probe must populate the
environment itself — either run it via `docker compose run` (so `env_file` applies) or load
`.env` explicitly first (e.g. `set -a; source .env; set +a`). If run in-container, confirm
container-to-Immich-Postgres network reachability.
- Read-only must be **enforced at the DB/session layer**, not just by which statements the
script issues: open the session read-only (`SET default_transaction_read_only = on` /
`SET TRANSACTION READ ONLY`) or connect via a role granted only `SELECT`. The simplest
credentials that work are Immich's existing Postgres user, but that user is write/DDL-capable
against the **source-of-truth** DB, so the session-level guard is required to remove write
capability while the spike runs. A dedicated least-privilege read-only role remains
recommended hardening for M1.5.
- Driver: `psycopg` (psycopg3), plus the **`pgvector`** Python package. psycopg3 returns a
`vector` column as a *string* unless the adapter is registered, so call
`pgvector.psycopg.register_vector(conn)` after connect — or read the dimension server-side
(`SELECT vector_dims(embedding) …` / catalog `atttypmod`) and run the `<=>` check in SQL,
which needs no adapter. Add these to the spike's dependencies; M1.5 will formalize them.
### The probe
A single disposable script, **`scripts/pgvector_spike.py`**, that is idempotent and
read-only. It:
1. connects using `IMMICH_DB_URL`;
2. discovers candidate embedding tables/columns from the catalog (probe `smart_search` /
`embedding` first, then fall back to scanning `information_schema` for `vector`-typed
columns) — so it survives version drift;
3. reads one embedding and reports its **dimension** — via the registered `pgvector` adapter
or server-side `vector_dims(embedding)`, **not** the length of a raw string — and a sample
of the **distance operator** working (e.g. a `... ORDER BY embedding <=> embedding LIMIT 5`
self-similarity sanity check, casting literals to `::vector` as needed);
4. verifies the **asset-ID join inside Postgres**: the embedding row's key references
`assets.id`. As *optional* confirmation it also checks whether that ID is one we store in
SQLite (`shared/photoflow/core`), degrading gracefully (not failing the check) when the
store is unpopulated;
5. computes **coverage** against the embeddable population:
`count(distinct embedding.assetId) / count(assets WHERE type = 'IMAGE')` (or Immich's
equivalent asset-type filter), recording **both** the raw and image-only ratios;
6. prints a human-readable report **and** writes/refreshes the findings doc.
It hardcodes nothing destructive, takes no write path, and is safe to re-run.
## Deliverables
1. `scripts/pgvector_spike.py` — disposable read-only probe (removed or left as a documented
one-off after M1.5 internalizes its findings).
2. `IMMICH_DB_URL` in `.env.example`; optional `immich_db_url` field in `config.py`. Spike
dependencies added: `psycopg` (psycopg3) and the `pgvector` Python package.
3. **`docs/superpowers/specs/2026-06-27-pgvector-embedding-findings.md`** — the one-page
schema that becomes M1.5's contract: table, embedding column, vector dimension, distance
operator, the embedding → `assetId` → SQLite `asset` join, coverage % (raw and image-only),
the model **and** Immich version observed, and the (optional) read-only-role recipe. The doc
must state that this is Immich's **unsupported internal schema**, valid only for the recorded
model + version, and that M1.5 re-probes / version-guards on every Immich upgrade.
4. Roadmap correction: fix **both** false claims named in "Why this exists" — the narrative
that M1 "runs a read-only feasibility spike" (→ "spike defined/pending per this spec") **and**
M1.5's status note "verified in M1" (→ "dependency spike pending/this spec").
## Running the spike
Prereqs: read access to Immich's Postgres and `IMMICH_DB_URL` set — put it in `.env` at the
repo root; the probe searches upward from the working directory to find it. DSN form:
`postgresql://USER:PASSWORD@HOST:PORT/DBNAME` (Immich defaults: user `postgres`, db `immich`;
URL-encode special chars in the password). Immich's stock compose does **not** publish Postgres
to the host, so either map its port or run where the DB is reachable on the LAN.
From the repo root (a fresh checkout / new worktree has no `.venv`):
```bash
python3 -m venv .venv
.venv/bin/python -m pip install -r scripts/requirements-spike.txt
.venv/bin/python scripts/pgvector_spike.py # read-only, idempotent, safe to re-run
```
The probe prints a report and (re)writes the findings doc. Flags: `--no-write` (report only,
leave the doc untouched), `--sqlite PATH` (optional SQLite cross-check target; skips gracefully
when absent), `--findings PATH` (write the doc elsewhere). The DB session is opened read-only at
the session layer, so the probe cannot mutate Immich even via the write-capable `postgres` user.
## Definition of done
The spike is **done** once Requirements 04 are answered and recorded in the findings doc:
REST insufficiency documented (R0), embeddings readable (R1), join to `asset` confirmed (R2),
shape pinned — table / column / dimension / distance operator (R3), and coverage recorded both
raw and image-only (R4). **Coverage value does not gate done-ness** — per Risks, near-zero
coverage is a recorded fact, not a failure. By these criteria the spike already passed on
2026-06-27 (see the findings doc); `ROADMAP.md` reflects this.
**Picking it up when the CLIP re-run reaches ~100%:** the only remaining action is to refresh the
coverage snapshot in the contract doc. Once the re-run completes, run
`scripts/pgvector_spike.py` (no flags) so the committed findings doc records the final
full-library coverage, then commit the regenerated doc. Nothing else changes — readability, the
`assetId → asset.id` join, and the vector shape are already locked and are re-verified on every
run. (If a later Immich upgrade changes the schema, the probe's catalog discovery adapts; re-run
and re-commit the doc — that is M1.5's version-guard duty.)
## Out of scope (explicitly M1.5)
CLIP clustering; a tested `shared/photoflow/immich/db.py` reader; nearest-neighbor queries at
scale; any use of the embeddings beyond the feasibility checks above.
## Risks
- **Embeddings unreadable / not joinable** → M1.5 blocked; spike's whole point is to surface
this early and cheaply.
- **Schema differs from the known shape** → expected and handled by catalog discovery, not
hardcoded names.
- **Coverage near zero** (re-run unfinished) → not a spike failure; recorded as a fact so M1.5
can sequence around it.
+3
View File
@@ -0,0 +1,3 @@
[tool.pytest.ini_options]
testpaths = ["shared/tests", "apps/trip-cluster/tests"]
addopts = "-q"
+583
View File
@@ -0,0 +1,583 @@
#!/usr/bin/env python3
"""pgvector embedding feasibility spike — disposable read-only probe.
Answers the M1.5 prerequisite questions against the *live* Immich Postgres and
records them in a findings doc. See the design spec:
docs/superpowers/specs/2026-06-27-pgvector-embedding-spike-design.md
Questions (pass/fail):
1. Can we read the CLIP embeddings at all?
2. Can each embedding join back to a photo we track (embedding key -> assets.id)?
3. Exact shapes: table, embedding column, vector dimension, distance operator.
4. Coverage over the *embeddable* (IMAGE) population raw and image-only ratios.
This is read-only and idempotent. The DB session is opened read-only at the
session layer (not merely by which statements we issue), so even Immich's
write-capable postgres user cannot mutate the source-of-truth DB while we probe.
Usage (from the repo root; a fresh checkout / new worktree has no .venv):
python3 -m venv .venv
.venv/bin/python -m pip install -r scripts/requirements-spike.txt
.venv/bin/python scripts/pgvector_spike.py # read-only, idempotent, safe to re-run
IMMICH_DB_URL is read from the environment, else from the nearest .env searched upward from the
working directory (postgresql://USER:PASS@HOST:PORT/DBNAME). Or pass it inline:
IMMICH_DB_URL=postgresql://user:pass@host:5432/immich .venv/bin/python scripts/pgvector_spike.py
Flags: --sqlite PATH (optional SQLite cross-check), --findings PATH, --no-write (report only).
See docs/superpowers/specs/2026-06-27-pgvector-embedding-spike-design.md for the definition of done.
"""
from __future__ import annotations
import argparse
import os
import sqlite3
import sys
import textwrap
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import psycopg
from psycopg import sql
# pgvector type candidates to probe first (verified against the catalog, not trusted).
KNOWN_TABLE = "smart_search"
KNOWN_COLUMN = "embedding"
# pgvector opclass -> (distance operator, human name).
OPCLASS_OPERATORS = {
"vector_cosine_ops": ("<=>", "cosine"),
"vector_l2_ops": ("<->", "L2 / euclidean"),
"vector_ip_ops": ("<#>", "negative inner product"),
}
DEFAULT_FINDINGS = "docs/superpowers/specs/2026-06-27-pgvector-embedding-findings.md"
# --------------------------------------------------------------------------- env
def find_dsn() -> Optional[str]:
"""IMMICH_DB_URL from the environment, else from the nearest .env walking up."""
dsn = (os.environ.get("IMMICH_DB_URL") or "").strip()
if dsn:
return dsn
here = Path.cwd().resolve()
for base in [here, *here.parents, Path(__file__).resolve().parent.parent]:
env_file = base / ".env"
if not env_file.is_file():
continue
for line in env_file.read_text().splitlines():
line = line.strip()
if line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
if key.strip() == "IMMICH_DB_URL":
val = val.strip().strip('"').strip("'")
if val:
return val
return None
def connect_readonly(dsn: str) -> psycopg.Connection:
"""Open a connection and force the whole session read-only.
Belt-and-suspenders: psycopg's ``read_only`` wraps each transaction READ ONLY,
and ``default_transaction_read_only`` covers anything that slips outside it.
"""
conn = psycopg.connect(dsn, autocommit=True)
conn.execute("SET default_transaction_read_only = on")
conn.read_only = True
return conn
# ----------------------------------------------------------------------- discovery
def discover_vector_columns(conn: psycopg.Connection) -> list[tuple[str, str, str]]:
"""All (schema, table, column) holding a pgvector ``vector`` column.
Catalog-driven so it survives Immich's cross-version schema drift. The known
``smart_search.embedding`` pair is sorted first when present.
"""
rows = conn.execute(
"""
SELECT n.nspname, c.relname, a.attname
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_type t ON t.oid = a.atttypid
WHERE t.typname = 'vector'
AND a.attnum > 0 AND NOT a.attisdropped
AND c.relkind IN ('r', 'p') -- ordinary + partitioned tables
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY (c.relname = %s AND a.attname = %s) DESC, n.nspname, c.relname
""",
(KNOWN_TABLE, KNOWN_COLUMN),
).fetchall()
return [(r[0], r[1], r[2]) for r in rows]
def vector_dimension(conn, schema: str, table: str, column: str) -> tuple[Optional[int], str]:
"""Vector dimension, preferring a live sample over the declared typmod."""
# atttypmod holds the declared dimension for pgvector (>0 when fixed).
typmod = conn.execute(
"""
SELECT a.atttypmod
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = %s AND c.relname = %s AND a.attname = %s
""",
(schema, table, column),
).fetchone()
declared = typmod[0] if typmod and typmod[0] and typmod[0] > 0 else None
sampled = None
try:
ident = sql.Identifier(schema, table)
col = sql.Identifier(column)
row = conn.execute(
sql.SQL("SELECT vector_dims({col}) FROM {tbl} WHERE {col} IS NOT NULL LIMIT 1")
.format(col=col, tbl=ident)
).fetchone()
sampled = row[0] if row else None
except Exception as exc: # noqa: BLE001 — record, don't abort the probe
return declared, f"declared typmod={declared}; live sample failed: {exc}"
if sampled is not None:
note = f"live sample={sampled}" + (f", declared typmod={declared}" if declared else "")
return sampled, note
return declared, f"declared typmod={declared}; table empty (no live vector to sample)"
def distance_operator(conn, schema: str, table: str, column: str) -> tuple[str, str]:
"""The distance operator Immich's index was built for, read from its opclass."""
row = conn.execute(
"""
SELECT opc.opcname
FROM pg_index i
JOIN pg_class ic ON ic.oid = i.indexrelid
JOIN pg_class tc ON tc.oid = i.indrelid
JOIN pg_namespace n ON n.oid = tc.relnamespace
JOIN pg_opclass opc ON opc.oid = ANY (i.indclass)
JOIN pg_attribute at ON at.attrelid = i.indrelid AND at.attnum = ANY (i.indkey)
WHERE n.nspname = %s AND tc.relname = %s AND at.attname = %s
AND opc.opcname LIKE 'vector_%%'
LIMIT 1
""",
(schema, table, column),
).fetchone()
if row and row[0] in OPCLASS_OPERATORS:
op, name = OPCLASS_OPERATORS[row[0]]
return op, f"{op} ({name}) — from index opclass {row[0]}"
return "<=>", "<=> (cosine) — assumed; no vector index opclass found on this column"
def self_similarity_ok(conn, schema, table, column, key_col, op) -> str:
"""Sanity-check the operator: a vector must be its own nearest neighbour (dist ~0).
Param-free (seed picked inside a CTE) so it works regardless of how psycopg
binds vector literals.
"""
try:
tbl, col, key = sql.Identifier(schema, table), sql.Identifier(column), sql.Identifier(key_col)
rows = conn.execute(
sql.SQL(
"WITH seed AS (SELECT {key} AS k, {col} AS v FROM {tbl} "
" WHERE {col} IS NOT NULL LIMIT 1) "
"SELECT (e.{key} = seed.k) AS is_seed, (e.{col} {op} seed.v) AS dist, "
" (seed.v {op} seed.v) AS self_dist "
"FROM {tbl} e, seed WHERE e.{col} IS NOT NULL ORDER BY dist ASC LIMIT 5"
).format(key=key, col=col, tbl=tbl, op=sql.SQL(op))
).fetchall()
if not rows:
return "skipped — no embeddings present"
is_seed, top_dist, self_dist = rows[0]
# Operator works iff an identical vector sorts first at the operator's own
# self-distance. That value is operator-dependent (0 for cosine <=> / L2 <->,
# ~-1 for inner product <#>), so compare the nearest distance to the seed's
# self-distance rather than to a hardcoded 0. The nearest row may be a duplicate
# photo rather than the seed itself — still a pass.
good = abs(float(top_dist) - float(self_dist)) < 1e-6
who = "the seed itself" if is_seed else "a duplicate (identical-vector) asset"
return (
f"{'OK' if good else 'UNEXPECTED'} — nearest neighbour ({who}) "
f"at distance {float(top_dist):.6g} (seed self-distance {float(self_dist):.6g})"
)
except Exception as exc: # noqa: BLE001
return f"failed: {exc}"
def detect_asset_key(conn, schema, table) -> tuple[Optional[str], Optional[str], Optional[str], str]:
"""Find the column + table joining the embedding row to the asset record.
Returns ``(key_col, asset_table, asset_pk, note)``. The asset table name is
*discovered from the FK*, never assumed Immich renamed ``assets`` -> ``asset``
across versions, exactly the drift the spec warns about.
"""
fks = conn.execute(
"""
SELECT kcu.column_name, ccu.table_name, ccu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = %s AND tc.table_name = %s
""",
(schema, table),
).fetchall()
# Prefer the FK whose target table is the asset record (singular/plural tolerated).
for col, ftable, fcol in fks:
if ftable in ("asset", "assets"):
return col, ftable, fcol, f"FK {table}.{col} -> {ftable}.{fcol}"
cols = {
r[0]
for r in conn.execute(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = %s AND table_name = %s",
(schema, table),
).fetchall()
}
existing_tables = {
r[0]
for r in conn.execute(
"SELECT table_name FROM information_schema.tables "
"WHERE table_schema = %s AND table_name IN ('asset', 'assets')",
(schema,),
).fetchall()
}
asset_table = "asset" if "asset" in existing_tables else ("assets" if "assets" in existing_tables else None)
for cand in ("assetId", "asset_id", "assetsId"):
if cand in cols and asset_table:
return cand, asset_table, "id", f"no FK; matched column {table}.{cand} -> {asset_table}.id (assumed)"
return None, None, None, "no asset-key column found (FK or assetId/asset_id)"
def join_and_coverage(conn, schema, table, key_col, asset_table, assets_pk):
"""Join validity + coverage (raw and image-only) inside Postgres.
Returns a dict (the contract its three callers read by key):
n_embeddings_distinct, n_joined_to_assets, n_orphan, n_assets_total: int
n_assets_image, n_embedded_image: int | None (None when no asset 'type' column)
raw_ratio, image_ratio: float | None (None when the denominator is 0/None)
"""
tbl, key = sql.Identifier(schema, table), sql.Identifier(key_col)
atbl, apk = sql.Identifier(schema, asset_table), sql.Identifier(assets_pk)
n_emb = conn.execute(
sql.SQL("SELECT count(DISTINCT {key}) FROM {tbl} WHERE {key} IS NOT NULL")
.format(key=key, tbl=tbl)
).fetchone()[0]
n_joined = conn.execute(
sql.SQL(
"SELECT count(DISTINCT e.{key}) FROM {tbl} e "
"JOIN {atbl} a ON a.{apk} = e.{key}"
).format(key=key, tbl=tbl, atbl=atbl, apk=apk)
).fetchone()[0]
n_assets_total = conn.execute(
sql.SQL("SELECT count(*) FROM {atbl}").format(atbl=atbl)
).fetchone()[0]
# Image-only population — Immich never embeds video/audio/other with CLIP.
has_type = bool(
conn.execute(
"SELECT 1 FROM information_schema.columns "
"WHERE table_schema = %s AND table_name = %s AND column_name = 'type' LIMIT 1",
(schema, asset_table),
).fetchone()
)
n_assets_image = n_emb_image = None
if has_type:
n_assets_image = conn.execute(
sql.SQL("SELECT count(*) FROM {atbl} WHERE type = 'IMAGE'").format(atbl=atbl)
).fetchone()[0]
n_emb_image = conn.execute(
sql.SQL(
"SELECT count(DISTINCT a.{apk}) FROM {atbl} a "
"JOIN {tbl} e ON e.{key} = a.{apk} WHERE a.type = 'IMAGE'"
).format(apk=apk, atbl=atbl, tbl=tbl, key=key)
).fetchone()[0]
return {
"n_embeddings_distinct": n_emb,
"n_joined_to_assets": n_joined,
"n_orphan": n_emb - n_joined,
"n_assets_total": n_assets_total,
"n_assets_image": n_assets_image,
"n_embedded_image": n_emb_image,
"raw_ratio": (n_joined / n_assets_total) if n_assets_total else None,
"image_ratio": (n_emb_image / n_assets_image)
if (n_assets_image not in (None, 0)) else None,
}
def sqlite_crosscheck(conn, schema, table, key_col, sqlite_path: Path) -> str:
"""Optional confirmation that embedding asset IDs match our SQLite store.
Degrades gracefully: a fresh checkout has no `ingest` run, so an empty/absent
store is not a failure of the join requirement.
NOTE: this opens SQLite directly, a deliberate exception for this throwaway probe.
Production M1.5 code must NOT it must go through shared/photoflow/core/store.py,
the only SQLite owner (CLAUDE.md). This raw read-only sample check stays in the spike.
"""
if not sqlite_path.is_file():
return f"skipped — no SQLite store at {sqlite_path} (fresh checkout; not a failure)"
try:
sample = [
str(r[0])
for r in conn.execute(
sql.SQL("SELECT {key} FROM {tbl} WHERE {key} IS NOT NULL LIMIT 50")
.format(key=sql.Identifier(key_col), tbl=sql.Identifier(schema, table))
).fetchall()
]
if not sample:
return "skipped — no embeddings to sample"
sconn = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
try:
tbl_exists = sconn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='assets'"
).fetchone()
if not tbl_exists:
return "skipped — SQLite store has no assets table (unpopulated)"
total = sconn.execute("SELECT count(*) FROM assets").fetchone()[0]
if total == 0:
return "skipped — SQLite assets table empty (no ingest run; not a failure)"
placeholders = ",".join("?" * len(sample))
hits = sconn.execute(
f"SELECT count(*) FROM assets WHERE immich_id IN ({placeholders})", sample
).fetchone()[0]
finally:
sconn.close()
return f"{hits}/{len(sample)} sampled embedding asset IDs found in SQLite (store has {total} assets)"
except Exception as exc: # noqa: BLE001
return f"failed (non-fatal): {exc}"
def environment_meta(conn) -> dict:
"""Best-effort version context. Immich's *server* version is not in the DB."""
out = {}
try:
out["postgres_version"] = conn.execute("SHOW server_version").fetchone()[0]
except Exception as exc: # noqa: BLE001
out["postgres_version"] = f"unknown: {exc}"
try:
row = conn.execute(
"SELECT extversion FROM pg_extension WHERE extname IN ('vector', 'vectors') LIMIT 1"
).fetchone()
out["pgvector_extension"] = row[0] if row else "not found"
except Exception as exc: # noqa: BLE001
out["pgvector_extension"] = f"unknown: {exc}"
# Newest applied migration as a proxy for schema age (table name varies by version).
out["latest_migration"] = "unknown"
for mig_table in ("migrations", "kysely_migration", "typeorm_metadata"):
try:
row = conn.execute(
sql.SQL("SELECT name FROM {} ORDER BY 1 DESC LIMIT 1")
.format(sql.Identifier(mig_table))
).fetchone()
if row:
out["latest_migration"] = f"{mig_table}: {row[0]}"
break
except Exception: # noqa: BLE001 — table absent in this version
continue
return out
# -------------------------------------------------------------------------- output
def _pct(ratio: Optional[float]) -> str:
"""Format a coverage ratio as a percentage, or 'n/a' when it could not be computed."""
return "n/a" if ratio is None else f"{ratio * 100:.1f}%"
def render_findings(facts: dict) -> str:
cov = facts["coverage"]
raw_pct = _pct(cov["raw_ratio"])
img_pct = _pct(cov["image_ratio"])
meta = facts["meta"]
return textwrap.dedent(
f"""\
# pgvector embedding feasibility spike — findings
**Generated:** {facts['generated']} by `scripts/pgvector_spike.py`
**Status:** machine-probed against the live Immich Postgres.
**This is M1.5's contract.** See the design spec:
`docs/superpowers/specs/2026-06-27-pgvector-embedding-spike-design.md`.
> **Immich's undocumented, internal schema — no deprecation contract.**
> Everything below is valid **only** for the model + Immich version observed and
> can be renamed/restructured on any Immich upgrade. M1.5 must re-run this probe
> (or version-guard) on every Immich upgrade. The "contract" is version-pinned,
> not durable.
## Requirement 0 — does M1.5 need raw vectors (vs. a REST query)?
M1.5 clusters photos by *visual similarity* at trip level it needs either the
raw CLIP embedding vectors or an arbitrary assetasset nearest-neighbour query.
Immich's REST surface provides neither:
- `POST /api/search/smart` **textimage** CLIP search: takes a text query,
returns assets. It never returns embedding vectors and cannot do assetasset
similarity without a text prompt. Insufficient.
- `POST /api/search/metadata`, `/api/search/random` metadata/random only; no
embeddings, no similarity.
- Duplicate detection (`/api/duplicates`) consumes embeddings *internally* but only
surfaces near-duplicate groups above Immich's own threshold — not a tunable
pairwise similarity usable for trip-level clustering. Insufficient.
- No documented endpoint returns raw CLIP vectors or arbitrary k-NN neighbours.
**Conclusion:** the load-bearing "REST can't expose embeddings" premise holds for
Immich's documented API → **read-only Postgres access (below) is the viable path.**
(Re-confirm against the OpenAPI of the running version on upgrade.)
## Requirements 14 — probed facts
| # | Question | Answer |
|---|----------|--------|
| 1 | Embeddings readable? | **{facts['readable']}** |
| 2 | Join to asset table? | **{facts['join_summary']}** |
| 3 | Table / column | `{facts['schema']}.{facts['table']}` / `{facts['column']}` |
| 3 | Vector dimension | **{facts['dimension']}** ({facts['dimension_note']}) |
| 3 | Distance operator | **{facts['operator']}** |
| 3 | Operator sanity check | {facts['self_similarity']} |
| 4 | Coverage (raw) | **{raw_pct}** ({cov['n_joined_to_assets']}/{cov['n_assets_total']} assets) |
| 4 | Coverage (image-only) | **{img_pct}** ({cov['n_embedded_image']}/{cov['n_assets_image']} IMAGE assets) |
- **Asset-key join:** {facts['asset_key_note']}
- **Orphan embeddings** (no matching asset): {cov['n_orphan']}
- **SQLite cross-check (optional):** {facts['sqlite']}
## The join M1.5 relies on
`{facts['table']}.{facts['asset_key']}` `{facts['asset_table']}.{facts['assets_pk']}`
SQLite `assets.immich_id` (`shared/photoflow/core`). The Immich asset UUID is the
same key our store uses, so embeddings index straight onto tracked photos.
> Immich's asset table is **`{facts['asset_table']}`** in the observed version
> (it was `assets` in older versions). The probe discovers this from the FK; M1.5's
> reader must not hardcode the name.
## Environment observed
- **Postgres:** {meta['postgres_version']}
- **pgvector extension:** {meta['pgvector_extension']}
- **Schema age proxy (latest migration):** {meta['latest_migration']}
- **Immich server version:** _record from the Immich UI / `GET /api/server/version`_
not reliably available in the DB.
- **CLIP model:** _record the model from Immich's Machine-Learning settings_ — the
user is re-running CLIP with a stronger model, so dimension + coverage above are a
snapshot of whichever model was live at probe time.
## Read-only access (recommended hardening for M1.5)
The probe enforces read-only at the session layer
(`SET default_transaction_read_only = on` + psycopg `read_only`), which neutralises
write capability even with Immich's write/DDL-capable `postgres` user. For M1.5,
provision a dedicated least-privilege role instead:
```sql
CREATE ROLE photoflow_ro LOGIN PASSWORD '...';
GRANT CONNECT ON DATABASE immich TO photoflow_ro;
GRANT USAGE ON SCHEMA {facts['schema']} TO photoflow_ro;
GRANT SELECT ON {facts['schema']}.{facts['table']}, {facts['schema']}.{facts['asset_table']} TO photoflow_ro;
```
"""
)
def print_report(facts: dict) -> None:
cov = facts["coverage"]
print("\n=== pgvector spike — report ===")
print(f" table/column : {facts['schema']}.{facts['table']}.{facts['column']}")
print(f" dimension : {facts['dimension']} ({facts['dimension_note']})")
print(f" distance operator: {facts['operator']}")
print(f" operator sanity : {facts['self_similarity']}")
print(f" asset key : {facts['asset_key_note']}")
print(f" join : {facts['join_summary']} (orphans: {cov['n_orphan']})")
print(f" coverage (raw) : {_pct(cov['raw_ratio'])} ({cov['n_joined_to_assets']}/{cov['n_assets_total']})")
print(f" coverage (image) : {_pct(cov['image_ratio'])} ({cov['n_embedded_image']}/{cov['n_assets_image']})")
print(f" sqlite x-check : {facts['sqlite']}")
print(f" postgres : {facts['meta']['postgres_version']}")
print(f" pgvector ext : {facts['meta']['pgvector_extension']}")
print("===============================\n")
# ---------------------------------------------------------------------------- main
def main() -> int:
ap = argparse.ArgumentParser(description="Read-only pgvector embedding feasibility probe.")
ap.add_argument("--sqlite", default="data/trip-cluster.db",
help="SQLite store path for the optional cross-check (default: data/trip-cluster.db)")
ap.add_argument("--findings", default=DEFAULT_FINDINGS, help="findings doc path to write")
ap.add_argument("--no-write", action="store_true", help="print the report but do not write the findings doc")
args = ap.parse_args()
dsn = find_dsn()
if not dsn:
print("ERROR: IMMICH_DB_URL not set (env or a .env up-tree). See .env.example.", file=sys.stderr)
return 2
try:
conn = connect_readonly(dsn)
except Exception as exc: # noqa: BLE001
print(f"ERROR: could not connect read-only: {exc}", file=sys.stderr)
return 2
with conn:
vec_cols = discover_vector_columns(conn)
if not vec_cols:
print("FAIL (Req 1): no pgvector `vector` column found in this database.", file=sys.stderr)
print(" Immich may not have run CLIP yet, or the schema changed beyond the probe's discovery.",
file=sys.stderr)
return 1
schema, table, column = vec_cols[0]
if len(vec_cols) > 1:
others = ", ".join(f"{s}.{t}.{c}" for s, t, c in vec_cols[1:])
print(f"NOTE: multiple vector columns found; probing {schema}.{table}.{column}. Others: {others}")
dim, dim_note = vector_dimension(conn, schema, table, column)
op, op_desc = distance_operator(conn, schema, table, column)
asset_key, asset_table, assets_pk, asset_key_note = detect_asset_key(conn, schema, table)
if not asset_key:
print(f"FAIL (Req 2): {asset_key_note}", file=sys.stderr)
return 1
self_sim = self_similarity_ok(conn, schema, table, column, asset_key, op)
cov = join_and_coverage(conn, schema, table, asset_key, asset_table, assets_pk)
sqlite_note = sqlite_crosscheck(conn, schema, table, asset_key, Path(args.sqlite))
meta = environment_meta(conn)
join_summary = (
f"{cov['n_joined_to_assets']}/{cov['n_embeddings_distinct']} embeddings join to "
f"{asset_table} via {asset_key}"
)
facts = {
"generated": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
"schema": schema, "table": table, "column": column,
"readable": f"yes — read {cov['n_embeddings_distinct']} distinct embedding(s)",
"dimension": dim if dim is not None else "unknown",
"dimension_note": dim_note,
"operator": op_desc,
"self_similarity": self_sim,
"asset_key": asset_key, "asset_table": asset_table, "assets_pk": assets_pk,
"asset_key_note": asset_key_note,
"join_summary": join_summary,
"coverage": cov, "sqlite": sqlite_note, "meta": meta,
}
print_report(facts)
join_clean = cov["n_orphan"] == 0 and cov["n_joined_to_assets"] > 0
if not args.no_write:
out = Path(args.findings)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(render_findings(facts))
print(f"Wrote findings -> {out}")
if cov["n_embeddings_distinct"] == 0:
print("WARN: zero embeddings — readable+well-shaped but coverage 0 (CLIP re-run unfinished?).")
if not join_clean:
print(f"WARN (Req 2): {cov['n_orphan']} orphan embedding(s) did not join to assets.")
return 0
if __name__ == "__main__":
sys.exit(main())
+5
View File
@@ -0,0 +1,5 @@
# Dependencies for the throwaway pgvector feasibility spike (scripts/pgvector_spike.py).
# Kept out of the app's runtime deps on purpose — only the spike / M1.5 needs Postgres access.
# Install into a venv: pip install -r scripts/requirements-spike.txt
psycopg[binary]==3.3.4
pgvector==0.4.2
View File
+3
View File
@@ -0,0 +1,3 @@
from photoflow.core.store import Store, SCHEMA_VERSION
__all__ = ["Store", "SCHEMA_VERSION"]
+66
View File
@@ -0,0 +1,66 @@
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class Asset:
immich_id: str
taken_at: str = ""
gps_lat: Optional[float] = None
gps_lon: Optional[float] = None
place_city: Optional[str] = None
place_country: Optional[str] = None
type: str = ""
has_gps: bool = False
thumb_path: Optional[str] = None
processed: bool = False
ingested_at: str = ""
updated_at: str = ""
@dataclass
class Tag:
name: str
immich_tag_id: Optional[str] = None
count: int = 0
@dataclass
class AssetTag:
immich_id: str
tag_name: str
@dataclass
class Cluster:
id: Optional[int] = None
start_at: str = ""
end_at: str = ""
count: int = 0
suggested_name: str = ""
confidence: float = 0.0
kind_guess: str = "trip" # trip | everyday
status: str = "pending" # pending|approved|non_trip|merged|split|skipped
decided_name: Optional[str] = None
reviewed_at: Optional[str] = None
notes: Optional[str] = None
@dataclass
class ClusterMember:
cluster_id: int
immich_id: str
member_confidence: float = 1.0
is_outlier: bool = False
included: bool = True
flagged_coverage: bool = False
@dataclass
class WritebackLog:
id: Optional[int]
immich_id: str
action: str # trip | non-trip | processed
tag: Optional[str]
result: str # ok | error:<reason>
applied_at: str
+349
View File
@@ -0,0 +1,349 @@
import sqlite3
from photoflow.core.models import Asset, Cluster, ClusterMember, Tag
SCHEMA_VERSION = 1
SCHEMA = """
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS assets (
immich_id TEXT PRIMARY KEY,
taken_at TEXT,
gps_lat REAL, gps_lon REAL,
place_city TEXT, place_country TEXT,
type TEXT,
has_gps INTEGER NOT NULL DEFAULT 0,
thumb_path TEXT,
processed INTEGER NOT NULL DEFAULT 0,
ingested_at TEXT,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS tags (
name TEXT PRIMARY KEY,
immich_tag_id TEXT,
count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS asset_tags (
immich_id TEXT NOT NULL,
tag_name TEXT NOT NULL,
PRIMARY KEY (immich_id, tag_name)
);
CREATE TABLE IF NOT EXISTS clusters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_at TEXT, end_at TEXT,
count INTEGER NOT NULL DEFAULT 0,
suggested_name TEXT,
confidence REAL NOT NULL DEFAULT 0,
kind_guess TEXT NOT NULL DEFAULT 'trip',
status TEXT NOT NULL DEFAULT 'pending',
decided_name TEXT,
reviewed_at TEXT,
notes TEXT
);
CREATE TABLE IF NOT EXISTS cluster_members (
cluster_id INTEGER NOT NULL,
immich_id TEXT NOT NULL,
member_confidence REAL NOT NULL DEFAULT 1.0,
is_outlier INTEGER NOT NULL DEFAULT 0,
included INTEGER NOT NULL DEFAULT 1,
flagged_coverage INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (cluster_id, immich_id)
);
CREATE TABLE IF NOT EXISTS writeback_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
immich_id TEXT NOT NULL,
action TEXT NOT NULL,
tag TEXT,
result TEXT NOT NULL,
applied_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_assets_taken_at ON assets(taken_at);
CREATE INDEX IF NOT EXISTS idx_members_cluster ON cluster_members(cluster_id);
CREATE INDEX IF NOT EXISTS idx_members_asset ON cluster_members(immich_id);
CREATE INDEX IF NOT EXISTS idx_writeback_lookup
ON writeback_log(immich_id, action, tag, result);
"""
class Store:
def __init__(self, db_path: str):
self.db_path = db_path
self._conn = None
def connect(self) -> "Store":
self._conn = sqlite3.connect(self.db_path)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA foreign_keys=ON")
self.migrate()
return self
@property
def conn(self) -> sqlite3.Connection:
if self._conn is None:
raise RuntimeError("Store not connected; call connect()")
return self._conn
def migrate(self) -> None:
self.conn.executescript(SCHEMA)
if self.get_meta("schema_version") is None:
self.set_meta("schema_version", str(SCHEMA_VERSION))
self.conn.commit()
def set_meta(self, key: str, value: str) -> None:
self.conn.execute(
"INSERT INTO meta(key, value) VALUES(?, ?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, value))
self.conn.commit()
def get_meta(self, key: str, default=None):
row = self.conn.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
return row["value"] if row else default
def close(self) -> None:
if self._conn is not None:
self._conn.close()
self._conn = None
def __enter__(self) -> "Store":
return self
def __exit__(self, *exc) -> None:
self.close()
def upsert_asset(self, a: Asset) -> None:
has_gps = 1 if (a.gps_lat is not None and a.gps_lon is not None) else 0
self.conn.execute(
"""INSERT INTO assets
(immich_id, taken_at, gps_lat, gps_lon, place_city, place_country,
type, has_gps, thumb_path, processed, ingested_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(immich_id) DO UPDATE SET
taken_at=excluded.taken_at, gps_lat=excluded.gps_lat,
gps_lon=excluded.gps_lon, place_city=excluded.place_city,
place_country=excluded.place_country, type=excluded.type,
has_gps=excluded.has_gps, thumb_path=excluded.thumb_path,
ingested_at=excluded.ingested_at, updated_at=excluded.updated_at""",
(a.immich_id, a.taken_at, a.gps_lat, a.gps_lon, a.place_city,
a.place_country, a.type, has_gps, a.thumb_path,
1 if a.processed else 0, a.ingested_at, a.updated_at))
self.conn.commit()
def _asset_from_row(self, r) -> Asset:
return Asset(
immich_id=r["immich_id"], taken_at=r["taken_at"],
gps_lat=r["gps_lat"], gps_lon=r["gps_lon"],
place_city=r["place_city"], place_country=r["place_country"],
type=r["type"], has_gps=bool(r["has_gps"]), thumb_path=r["thumb_path"],
processed=bool(r["processed"]), ingested_at=r["ingested_at"],
updated_at=r["updated_at"])
def get_asset(self, immich_id: str):
r = self.conn.execute("SELECT * FROM assets WHERE immich_id=?", (immich_id,)).fetchone()
return self._asset_from_row(r) if r else None
def all_assets(self, include_processed: bool = True) -> list:
sql = "SELECT * FROM assets"
if not include_processed:
sql += " WHERE processed=0"
sql += " ORDER BY taken_at"
return [self._asset_from_row(r) for r in self.conn.execute(sql)]
def assets_in_range(self, start_at: str, end_at: str) -> list:
return [self._asset_from_row(r) for r in self.conn.execute(
"SELECT * FROM assets WHERE taken_at>=? AND taken_at<=? ORDER BY taken_at",
(start_at, end_at))]
def mark_processed(self, immich_id: str) -> None:
self.conn.execute("UPDATE assets SET processed=1 WHERE immich_id=?", (immich_id,))
self.conn.commit()
def set_asset_tags(self, immich_id: str, tag_names: list) -> None:
self.conn.execute("DELETE FROM asset_tags WHERE immich_id=?", (immich_id,))
self.conn.executemany(
"INSERT OR IGNORE INTO asset_tags(immich_id, tag_name) VALUES(?, ?)",
[(immich_id, n) for n in tag_names])
self.conn.commit()
def asset_tags(self, immich_id: str) -> list:
return [r["tag_name"] for r in self.conn.execute(
"SELECT tag_name FROM asset_tags WHERE immich_id=? ORDER BY tag_name",
(immich_id,))]
def upsert_tag(self, name: str, immich_tag_id=None, count: int = 0) -> None:
self.conn.execute(
"""INSERT INTO tags(name, immich_tag_id, count) VALUES(?,?,?)
ON CONFLICT(name) DO UPDATE SET
immich_tag_id=excluded.immich_tag_id, count=excluded.count""",
(name, immich_tag_id, count))
self.conn.commit()
def all_tags(self) -> list:
return [Tag(name=r["name"], immich_tag_id=r["immich_tag_id"], count=r["count"])
for r in self.conn.execute("SELECT * FROM tags ORDER BY name")]
def insert_cluster(self, c: Cluster, members: list) -> int:
cur = self.conn.execute(
"""INSERT INTO clusters
(start_at, end_at, count, suggested_name, confidence, kind_guess,
status, decided_name, reviewed_at, notes)
VALUES (?,?,?,?,?,?,?,?,?,?)""",
(c.start_at, c.end_at, c.count or len(members), c.suggested_name,
c.confidence, c.kind_guess, c.status, c.decided_name, c.reviewed_at, c.notes))
cid = cur.lastrowid
self.conn.executemany(
"""INSERT OR REPLACE INTO cluster_members
(cluster_id, immich_id, member_confidence, is_outlier, included, flagged_coverage)
VALUES (?,?,?,?,?,?)""",
[(cid, m.immich_id, m.member_confidence, 1 if m.is_outlier else 0,
1 if m.included else 0, 1 if m.flagged_coverage else 0) for m in members])
self.conn.commit()
return cid
def clear_clusters(self) -> None:
self.conn.execute("DELETE FROM cluster_members")
self.conn.execute("DELETE FROM clusters")
self.conn.commit()
def _cluster_from_row(self, r) -> Cluster:
return Cluster(
id=r["id"], start_at=r["start_at"], end_at=r["end_at"], count=r["count"],
suggested_name=r["suggested_name"], confidence=r["confidence"],
kind_guess=r["kind_guess"], status=r["status"], decided_name=r["decided_name"],
reviewed_at=r["reviewed_at"], notes=r["notes"])
def get_cluster(self, cluster_id: int):
r = self.conn.execute("SELECT * FROM clusters WHERE id=?", (cluster_id,)).fetchone()
return self._cluster_from_row(r) if r else None
def all_clusters(self) -> list:
return [self._cluster_from_row(r)
for r in self.conn.execute("SELECT * FROM clusters ORDER BY start_at, id")]
def clusters_by_attention(self) -> list:
return [self._cluster_from_row(r) for r in self.conn.execute(
"""SELECT * FROM clusters
ORDER BY CASE status WHEN 'pending' THEN 0 ELSE 1 END,
confidence ASC,
(julianday(end_at) - julianday(start_at)) DESC,
id""")]
def cluster_members(self, cluster_id: int) -> list:
rows = self.conn.execute(
"""SELECT a.*, m.member_confidence AS m_conf, m.is_outlier AS m_out,
m.included AS m_inc, m.flagged_coverage AS m_cov
FROM cluster_members m JOIN assets a ON a.immich_id = m.immich_id
WHERE m.cluster_id=? ORDER BY a.taken_at, a.immich_id""", (cluster_id,))
out = []
for r in rows:
asset = self._asset_from_row(r)
member = ClusterMember(
cluster_id=cluster_id, immich_id=r["immich_id"],
member_confidence=r["m_conf"], is_outlier=bool(r["m_out"]),
included=bool(r["m_inc"]), flagged_coverage=bool(r["m_cov"]))
out.append((asset, member))
return out
def chronological_neighbors(self, cluster_id: int):
ids = [r["id"] for r in self.conn.execute(
"SELECT id FROM clusters ORDER BY start_at, id")]
if cluster_id not in ids:
return (None, None)
i = ids.index(cluster_id)
prev_id = ids[i - 1] if i > 0 else None
next_id = ids[i + 1] if i < len(ids) - 1 else None
return (prev_id, next_id)
def update_cluster(self, cluster_id: int, *, status=None, decided_name=None,
suggested_name=None, notes=None, reviewed_at=None) -> None:
sets, vals = [], []
for col, val in [("status", status), ("decided_name", decided_name),
("suggested_name", suggested_name), ("notes", notes),
("reviewed_at", reviewed_at)]:
if val is not None:
sets.append(f"{col}=?")
vals.append(val)
if not sets:
return
vals.append(cluster_id)
self.conn.execute(f"UPDATE clusters SET {', '.join(sets)} WHERE id=?", vals)
self.conn.commit()
def set_member_inclusion(self, cluster_id: int, immich_id: str, included: bool) -> None:
self.conn.execute(
"UPDATE cluster_members SET included=? WHERE cluster_id=? AND immich_id=?",
(1 if included else 0, cluster_id, immich_id))
self.conn.commit()
def _recompute_span(self, members: list) -> tuple:
pairs = members
starts = [a.taken_at for a, _ in pairs]
return (min(starts), max(starts)) if starts else ("", "")
def split_cluster(self, cluster_id: int, boundary_immich_id: str):
pairs = self.cluster_members(cluster_id)
ids = [a.immich_id for a, _ in pairs]
if boundary_immich_id not in ids:
raise ValueError(f"boundary {boundary_immich_id!r} not in cluster")
idx = ids.index(boundary_immich_id)
if idx == 0:
raise ValueError(
f"boundary {boundary_immich_id!r} is the first member; "
"split would leave an empty cluster")
base = self.get_cluster(cluster_id)
left, right = pairs[:idx], pairs[idx:]
def _new(part, suffix):
ms = [ClusterMember(cluster_id=0, immich_id=a.immich_id,
member_confidence=m.member_confidence,
is_outlier=m.is_outlier, included=m.included,
flagged_coverage=m.flagged_coverage) for a, m in part]
start, end = self._recompute_span(part)
return self.insert_cluster(Cluster(
start_at=start, end_at=end, count=len(ms),
suggested_name=f"{base.suggested_name} ({suffix})",
confidence=base.confidence, kind_guess=base.kind_guess,
status="pending"), ms)
id1, id2 = _new(left, 1), _new(right, 2)
self.update_cluster(cluster_id, status="split")
return (id1, id2)
def merge_clusters(self, cluster_id_a: int, cluster_id_b: int):
pairs = self.cluster_members(cluster_id_a) + self.cluster_members(cluster_id_b)
pairs.sort(key=lambda p: (p[0].taken_at, p[0].immich_id))
base = self.get_cluster(cluster_id_a)
ms = [ClusterMember(cluster_id=0, immich_id=a.immich_id,
member_confidence=m.member_confidence, is_outlier=m.is_outlier,
included=m.included, flagged_coverage=m.flagged_coverage)
for a, m in pairs]
start, end = self._recompute_span(pairs)
new = self.insert_cluster(Cluster(
start_at=start, end_at=end, count=len(ms),
suggested_name=base.suggested_name, confidence=base.confidence,
kind_guess=base.kind_guess, status="pending"), ms)
self.update_cluster(cluster_id_a, status="merged")
self.update_cluster(cluster_id_b, status="merged")
return new
def log_writeback(self, immich_id: str, action: str, tag, result: str) -> None:
import datetime
self.conn.execute(
"INSERT INTO writeback_log(immich_id, action, tag, result, applied_at) "
"VALUES (?,?,?,?,?)",
(immich_id, action, tag, result,
datetime.datetime.now(datetime.timezone.utc).isoformat()))
self.conn.commit()
def already_applied(self, immich_id: str, action: str, tag) -> bool:
row = self.conn.execute(
"SELECT 1 FROM writeback_log WHERE immich_id=? AND action=? "
"AND IFNULL(tag,'')=IFNULL(?, '') AND result='ok' LIMIT 1",
(immich_id, action, tag)).fetchone()
return row is not None
+4
View File
@@ -0,0 +1,4 @@
from photoflow.immich.client import ImmichClient
from photoflow.immich import pipeline
__all__ = ["ImmichClient", "pipeline"]
+93
View File
@@ -0,0 +1,93 @@
import requests
def _normalize(item: dict) -> dict:
exif = item.get("exifInfo") or {}
lat = exif.get("latitude")
lon = exif.get("longitude")
return {
"id": item["id"],
"original_filename": item.get("originalFileName", ""),
"taken_at": item.get("localDateTime", ""),
"gps_lat": float(lat) if lat is not None else None,
"gps_lon": float(lon) if lon is not None else None,
"place_city": exif.get("city"),
"place_country": exif.get("country"),
"type": item.get("type", ""),
"tags": [t.get("name", "") for t in (item.get("tags") or [])],
"rating": int(exif.get("rating") or 0),
"updated_at": item.get("updatedAt", ""),
}
class ImmichClient:
def __init__(self, base_url: str, api_key: str, timeout: int = 30):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({"x-api-key": api_key, "Accept": "application/json"})
def _url(self, path: str) -> str:
return f"{self.base_url}{path}"
def list_tags(self) -> list[dict]:
r = self.session.get(self._url("/api/tags"), timeout=self.timeout)
r.raise_for_status()
return r.json()
def resolve_tag_id(self, name: str):
for tag in self.list_tags():
if tag.get("value") == name or tag.get("name") == name:
return tag["id"]
return None
def search_assets(self, *, taken_after=None, taken_before=None,
tag_ids=None, updated_after=None) -> list[dict]:
body = {"withExif": True}
if taken_after:
body["takenAfter"] = taken_after
if taken_before:
body["takenBefore"] = taken_before
if tag_ids:
body["tagIds"] = tag_ids
if updated_after:
body["updatedAfter"] = updated_after
out = []
page = 1
while True:
payload = dict(body, size=1000, page=page)
r = self.session.post(self._url("/api/search/metadata"),
json=payload, timeout=self.timeout)
r.raise_for_status()
block = r.json().get("assets", {})
out.extend(_normalize(it) for it in block.get("items", []))
nxt = block.get("nextPage")
if not nxt:
break
page = int(nxt)
return out
def download_thumbnail(self, asset_id: str) -> bytes:
r = self.session.get(self._url(f"/api/assets/{asset_id}/thumbnail?size=preview"),
timeout=self.timeout)
r.raise_for_status()
return r.content
def upsert_tag(self, name: str) -> str:
r = self.session.put(self._url("/api/tags"),
json={"tags": [name]}, timeout=self.timeout)
r.raise_for_status()
for tag in r.json():
if tag.get("value") == name or tag.get("name") == name:
return tag["id"]
resolved = self.resolve_tag_id(name)
if resolved is None:
raise RuntimeError(f"upsert_tag: could not resolve id for {name!r}")
return resolved
def tag_assets(self, tag_id: str, asset_ids: list[str]) -> None:
if not asset_ids:
return
r = self.session.put(self._url(f"/api/tags/{tag_id}/assets"),
json={"ids": asset_ids}, timeout=self.timeout)
r.raise_for_status()
+11
View File
@@ -0,0 +1,11 @@
ROOT = "_pipeline"
PROCESSED = f"{ROOT}/processed"
NON_TRIP = f"{ROOT}/non-trip"
def ai_rating(n: int) -> str:
return f"{ROOT}/ai-rating/{n}"
def is_pipeline_tag(name: str) -> bool:
return name == ROOT or name.startswith(ROOT + "/")
+16
View File
@@ -0,0 +1,16 @@
import os
from flask import Blueprint, Flask
from jinja2 import ChoiceLoader, FileSystemLoader
TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "templates")
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
def register_shared_ui(app: Flask) -> None:
app.jinja_loader = ChoiceLoader([app.jinja_loader, FileSystemLoader(TEMPLATE_DIR)])
bp = Blueprint("shared_ui", __name__, static_folder=STATIC_DIR,
static_url_path="/shared-static")
app.register_blueprint(bp)
__all__ = ["TEMPLATE_DIR", "STATIC_DIR", "register_shared_ui"]
+57
View File
@@ -0,0 +1,57 @@
// Generic grid + lightbox behavior shared across photoflow apps.
// Apps spread this into their own Alpine component: { ...photoGrid(), ...appLogic }
function photoGrid() {
return {
focused: null,
lightboxOpen: false,
cards() {
return [...document.querySelectorAll('.photo-card')]
.filter(c => c.style.display !== 'none');
},
select(el) {
if (this.focused) this.focused.classList.remove('ring-4', 'ring-white', 'z-10');
this.focused = el;
if (el) {
el.classList.add('ring-4', 'ring-white', 'z-10');
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
if (this.lightboxOpen) this.updateLightbox();
}
},
selectFirst() {
const cards = this.cards();
if (cards.length) this.select(cards[0]);
},
navigate(dir) {
const cards = this.cards();
if (!cards.length) return;
const idx = this.focused ? cards.indexOf(this.focused) : -1;
const next = cards[Math.max(0, Math.min(cards.length - 1, idx + dir))];
if (next) this.select(next);
},
openLightbox(el) {
this.select(el);
this.lightboxOpen = true;
document.getElementById('lb').style.display = '';
this.updateLightbox();
},
closeLightbox() {
this.lightboxOpen = false;
const lb = document.getElementById('lb');
if (lb) lb.style.display = 'none';
},
updateLightbox() {
const el = this.focused;
if (!el) return;
const img = el.querySelector('img');
const lbImg = document.getElementById('lb-img');
if (img && lbImg) lbImg.src = img.src;
},
};
}
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html data-theme="forest" lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}photoflow{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
</head>
<body class="min-h-screen bg-base-200">
<div class="navbar bg-base-100 shadow-sm sticky top-0 z-40">
<div class="navbar-start px-4 font-bold text-lg">
<a href="/">{% block navbar_title %}photoflow{% endblock %}</a>
</div>
</div>
<div class="p-4">
{% block content %}{% endblock %}
</div>
<script src="/shared-static/shared.js"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
{% macro status_badge(status) %}
{% set cls = {'pending':'badge-ghost','approved':'badge-success','non_trip':'badge-neutral',
'skipped':'badge-warning','merged':'badge-info','split':'badge-info'} %}
<span class="badge badge-sm {{ cls.get(status, 'badge-ghost') }}">{{ status }}</span>
{% endmacro %}
{% macro confidence_badge(confidence) %}
{% if confidence < 0.4 %}
<span class="badge badge-sm badge-warning" title="needs your eye">low</span>
{% elif confidence < 0.75 %}
<span class="badge badge-sm badge-info">med</span>
{% else %}
<span class="badge badge-sm badge-success" title="high-confidence">high</span>
{% endif %}
{% endmacro %}
{% macro lightbox() %}
<div id="lb" class="fixed inset-0 z-50 bg-black/95 flex items-center justify-center" style="display:none">
<button class="absolute top-4 right-4 btn btn-circle btn-sm btn-ghost text-white"
@click="closeLightbox()">&#10005;</button>
<button class="absolute left-3 top-1/2 -translate-y-1/2 btn btn-circle btn-ghost text-white text-4xl"
@click="navigate(-1)">&#8249;</button>
<button class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-circle btn-ghost text-white text-4xl"
@click="navigate(1)">&#8250;</button>
<div class="flex flex-col items-center gap-3 px-16 max-w-full">
<img id="lb-img" src="" class="max-h-[80vh] max-w-[88vw] object-contain rounded-lg" alt="">
<div class="text-white/40 text-xs">&larr; &rarr; navigate · Esc close</div>
</div>
</div>
{% endmacro %}
+16
View File
@@ -0,0 +1,16 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "photoflow"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["requests==2.32.3", "flask==3.1.0", "Pillow==11.0.0"]
[tool.setuptools.packages.find]
where = ["."]
include = ["photoflow*"]
[tool.setuptools.package-data]
"photoflow.ui" = ["templates/*.html", "static/*.js"]
+63
View File
@@ -0,0 +1,63 @@
from werkzeug.wrappers import Response
from photoflow.immich import ImmichClient
def test_resolve_tag_id_matches_name_or_value(httpserver):
httpserver.expect_request("/api/tags").respond_with_json([
{"id": "t1", "name": "Italy 2019", "value": "Italy 2019"},
{"id": "t2", "name": "processed", "value": "_pipeline/processed"},
])
c = ImmichClient(httpserver.url_for(""), "k")
assert c.resolve_tag_id("Italy 2019") == "t1"
assert c.resolve_tag_id("_pipeline/processed") == "t2"
assert c.resolve_tag_id("nope") is None
def test_search_assets_normalizes_and_paginates(httpserver):
def handler(request):
page = request.json.get("page", 1)
assert request.json.get("withExif") is True
if page == 1:
return Response(
'{"assets": {"items": [{"id": "a", "originalFileName": "a.jpg",'
' "localDateTime": "2019-06-01T10:00:00.000Z", "type": "IMAGE",'
' "updatedAt": "2026-01-01T00:00:00Z",'
' "exifInfo": {"latitude": 45.4, "longitude": 12.3, "city": "Venezia",'
' "country": "Italy", "rating": 4},'
' "tags": [{"name": "Italy 2019"}]}], "nextPage": 2}}',
content_type="application/json")
return Response(
'{"assets": {"items": [{"id": "b", "originalFileName": "b.jpg",'
' "localDateTime": "2019-06-02T11:00:00.000Z", "type": "IMAGE",'
' "updatedAt": "2026-01-02T00:00:00Z", "exifInfo": {}, "tags": []}],'
' "nextPage": null}}', content_type="application/json")
httpserver.expect_request("/api/search/metadata", method="POST").respond_with_handler(handler)
c = ImmichClient(httpserver.url_for(""), "k")
assets = c.search_assets(taken_after="2019-01-01", taken_before="2020-01-01")
assert [a["id"] for a in assets] == ["a", "b"]
a = assets[0]
assert a["gps_lat"] == 45.4 and a["place_city"] == "Venezia"
assert a["tags"] == ["Italy 2019"] and a["rating"] == 4
assert a["taken_at"] == "2019-06-01T10:00:00.000Z"
assert assets[1]["gps_lat"] is None and assets[1]["tags"] == []
def test_download_thumbnail(httpserver):
httpserver.expect_request("/api/assets/a/thumbnail").respond_with_data(
b"\xff\xd8\xffjpegbytes", content_type="image/jpeg")
c = ImmichClient(httpserver.url_for(""), "k")
assert c.download_thumbnail("a").startswith(b"\xff\xd8\xff")
def test_upsert_tag_returns_id_by_value(httpserver):
httpserver.expect_request("/api/tags", method="PUT").respond_with_json(
[{"id": "p1", "name": "non-trip", "value": "_pipeline/non-trip"}])
c = ImmichClient(httpserver.url_for(""), "k")
assert c.upsert_tag("_pipeline/non-trip") == "p1"
def test_tag_assets_posts_ids(httpserver):
httpserver.expect_request("/api/tags/p1/assets", method="PUT").respond_with_json({"ok": True})
c = ImmichClient(httpserver.url_for(""), "k")
c.tag_assets("p1", ["a", "b"]) # should not raise
+4
View File
@@ -0,0 +1,4 @@
def test_shared_packages_import():
import photoflow.immich
import photoflow.core
import photoflow.ui
+15
View File
@@ -0,0 +1,15 @@
from photoflow.immich import pipeline
def test_constants_and_helpers():
assert pipeline.ROOT == "_pipeline"
assert pipeline.PROCESSED == "_pipeline/processed"
assert pipeline.NON_TRIP == "_pipeline/non-trip"
assert pipeline.ai_rating(4) == "_pipeline/ai-rating/4"
def test_is_pipeline_tag():
assert pipeline.is_pipeline_tag("_pipeline/processed") is True
assert pipeline.is_pipeline_tag("_pipeline") is True
assert pipeline.is_pipeline_tag("Italy 2019") is False
assert pipeline.is_pipeline_tag("Kiev") is False
+81
View File
@@ -0,0 +1,81 @@
from photoflow.core import Store
def test_connect_creates_schema_and_version(tmp_path):
db = str(tmp_path / "t.db")
s = Store(db).connect()
assert s.get_meta("schema_version") == "1"
# tables exist
names = {r["name"] for r in s.conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'")}
assert {"assets", "tags", "asset_tags", "clusters",
"cluster_members", "writeback_log", "meta"} <= names
s.close()
def test_meta_roundtrip_and_default(tmp_path):
s = Store(str(tmp_path / "t.db")).connect()
assert s.get_meta("missing") is None
assert s.get_meta("missing", "x") == "x"
s.set_meta("last_ingest_at", "2026-06-27T00:00:00Z")
assert s.get_meta("last_ingest_at") == "2026-06-27T00:00:00Z"
s.set_meta("last_ingest_at", "newer") # upsert
assert s.get_meta("last_ingest_at") == "newer"
s.close()
from photoflow.core.models import Asset
def _store(tmp_path):
return Store(str(tmp_path / "t.db")).connect()
def test_upsert_asset_roundtrip_and_has_gps(tmp_path):
s = _store(tmp_path)
s.upsert_asset(Asset(immich_id="a", taken_at="2019-06-01T10:00:00",
gps_lat=45.4, gps_lon=12.3, place_city="Venezia", type="IMAGE"))
got = s.get_asset("a")
assert got.place_city == "Venezia" and got.has_gps is True
s.upsert_asset(Asset(immich_id="b", taken_at="2019-06-02T10:00:00"))
assert s.get_asset("b").has_gps is False
s.close()
def test_upsert_preserves_processed(tmp_path):
s = _store(tmp_path)
s.upsert_asset(Asset(immich_id="a", taken_at="t"))
s.mark_processed("a")
s.upsert_asset(Asset(immich_id="a", taken_at="t2")) # re-ingest
got = s.get_asset("a")
assert got.processed is True and got.taken_at == "t2"
s.close()
def test_all_assets_ordered_and_range(tmp_path):
s = _store(tmp_path)
for i, t in [("c", "2019-06-03"), ("a", "2019-06-01"), ("b", "2019-06-02")]:
s.upsert_asset(Asset(immich_id=i, taken_at=t))
assert [a.immich_id for a in s.all_assets()] == ["a", "b", "c"]
rng = s.assets_in_range("2019-06-02", "2019-06-03")
assert [a.immich_id for a in rng] == ["b", "c"]
s.close()
def test_asset_tags_replace(tmp_path):
s = _store(tmp_path)
s.upsert_asset(Asset(immich_id="a", taken_at="t"))
s.set_asset_tags("a", ["Italy 2019", "Kiev"])
assert sorted(s.asset_tags("a")) == ["Italy 2019", "Kiev"]
s.set_asset_tags("a", ["Italy 2019"]) # replace
assert s.asset_tags("a") == ["Italy 2019"]
s.close()
def test_tags_inventory(tmp_path):
s = _store(tmp_path)
s.upsert_tag("Italy 2019", immich_tag_id="t1", count=42)
s.upsert_tag("Italy 2019", immich_tag_id="t1", count=43) # upsert
names = {t.name: t for t in s.all_tags()}
assert names["Italy 2019"].count == 43 and names["Italy 2019"].immich_tag_id == "t1"
s.close()
+110
View File
@@ -0,0 +1,110 @@
from photoflow.core import Store
from photoflow.core.models import Asset, Cluster, ClusterMember
def _seed(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"),
("d", "2019-07-01"), ("e", "2019-07-02")]:
s.upsert_asset(Asset(immich_id=i, taken_at=t))
return s
def _members(ids):
return [ClusterMember(cluster_id=0, immich_id=i) for i in ids]
def test_insert_and_members_ordered(tmp_path):
s = _seed(tmp_path)
cid = s.insert_cluster(Cluster(start_at="2019-06-01", end_at="2019-06-03",
count=3, suggested_name="Trip 1", confidence=0.9),
_members(["c", "a", "b"]))
pairs = s.cluster_members(cid)
assert [a.immich_id for a, m in pairs] == ["a", "b", "c"]
assert s.get_cluster(cid).suggested_name == "Trip 1"
s.close()
def test_attention_sort(tmp_path):
s = _seed(tmp_path)
s.insert_cluster(Cluster(start_at="2019-06-01", end_at="2019-06-10",
confidence=0.9, status="pending"), _members(["a"]))
low = s.insert_cluster(Cluster(start_at="2019-07-01", end_at="2019-07-02",
confidence=0.2, status="pending"), _members(["d"]))
s.insert_cluster(Cluster(start_at="2019-06-02", end_at="2019-06-03",
confidence=0.1, status="approved"), _members(["b"]))
order = [c.id for c in s.clusters_by_attention()]
assert order[0] == low # lowest-confidence pending first
assert order[-1] != low # approved sinks to the bottom
s.close()
def test_chronological_neighbors(tmp_path):
s = _seed(tmp_path)
c1 = s.insert_cluster(Cluster(start_at="2019-06-01", end_at="2019-06-03"), _members(["a"]))
c2 = s.insert_cluster(Cluster(start_at="2019-07-01", end_at="2019-07-02"), _members(["d"]))
assert s.chronological_neighbors(c1) == (None, c2)
assert s.chronological_neighbors(c2) == (c1, None)
s.close()
def test_update_and_member_inclusion(tmp_path):
s = _seed(tmp_path)
cid = s.insert_cluster(Cluster(start_at="2019-06-01", end_at="2019-06-03"),
_members(["a", "b"]))
s.update_cluster(cid, status="approved", decided_name="Venice", reviewed_at="now")
c = s.get_cluster(cid)
assert c.status == "approved" and c.decided_name == "Venice"
s.set_member_inclusion(cid, "b", False)
inc = {m.immich_id: m.included for _, m in s.cluster_members(cid)}
assert inc == {"a": True, "b": False}
s.close()
def test_split_cluster(tmp_path):
s = _seed(tmp_path)
cid = s.insert_cluster(Cluster(start_at="2019-06-01", end_at="2019-06-03",
suggested_name="Trip"), _members(["a", "b", "c"]))
id1, id2 = s.split_cluster(cid, "c") # boundary "c" begins the second cluster
assert s.get_cluster(cid).status == "split"
left = [a.immich_id for a, _ in s.cluster_members(id1)]
right = [a.immich_id for a, _ in s.cluster_members(id2)]
assert left == ["a", "b"] and right == ["c"]
assert s.get_cluster(id1).status == "pending"
s.close()
def test_merge_clusters(tmp_path):
s = _seed(tmp_path)
a = s.insert_cluster(Cluster(start_at="2019-06-01", end_at="2019-06-03",
suggested_name="A"), _members(["a", "b"]))
b = s.insert_cluster(Cluster(start_at="2019-07-01", end_at="2019-07-02",
suggested_name="B"), _members(["d", "e"]))
new = s.merge_clusters(a, b)
assert s.get_cluster(a).status == "merged" and s.get_cluster(b).status == "merged"
ids = [x.immich_id for x, _ in s.cluster_members(new)]
assert ids == ["a", "b", "d", "e"]
c = s.get_cluster(new)
assert c.start_at == "2019-06-01" and c.end_at == "2019-07-02" and c.status == "pending"
s.close()
def test_writeback_log_idempotency(tmp_path):
s = _seed(tmp_path)
assert s.already_applied("a", "trip", "Venice") is False
s.log_writeback("a", "trip", "Venice", "ok")
assert s.already_applied("a", "trip", "Venice") is True
s.log_writeback("b", "trip", "Venice", "error:boom")
assert s.already_applied("b", "trip", "Venice") is False # only ok counts
s.close()
def test_split_cluster_rejects_first_member_boundary(tmp_path):
import pytest
s = _seed(tmp_path)
cid = s.insert_cluster(Cluster(start_at="2019-06-01", end_at="2019-06-03",
suggested_name="Trip"), _members(["a", "b", "c"]))
with pytest.raises(ValueError):
s.split_cluster(cid, "a") # boundary == first member -> empty left
assert s.get_cluster(cid).status != "split" # original untouched on rejection
s.close()
+24
View File
@@ -0,0 +1,24 @@
import os
from flask import Flask, render_template
from photoflow.ui import TEMPLATE_DIR, STATIC_DIR, register_shared_ui
def test_dirs_exist():
assert os.path.isfile(os.path.join(TEMPLATE_DIR, "base.html"))
assert os.path.isfile(os.path.join(STATIC_DIR, "shared.js"))
def test_register_serves_shared_static_and_template():
app = Flask(__name__)
register_shared_ui(app)
@app.route("/page")
def page():
return render_template("base.html")
client = app.test_client()
r = client.get("/page")
assert r.status_code == 200
assert b"/shared-static/shared.js" in r.data
js = client.get("/shared-static/shared.js")
assert js.status_code == 200 and b"photoGrid" in js.data