240 Commits

Author SHA1 Message Date
Cameron Cordes 48a1b753f0 AI: add enable_thinking reasoning toggle plumbed to llama.cpp
New optional SamplingOverride forwarded to llama-server as
chat_template_kwargs.enable_thinking (gates Qwen3-style reasoning
blocks). None leaves the template default; other backends ignore it.
Wired through the agentic-insight and chat-turn request bodies/handlers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:14:44 -04:00
Cameron Cordes f2ab8d3740 Unified search: use ANY-mode tag matching, not ALL
ALL-mode over-constrains NL queries — the model maps several query words to
tags and few photos carry every one, zeroing the candidate set. Switch to
ANY (a photo matches if it has any named tag); the semantic CLIP ranking
provides precision within that pool. Exclude tags still filter out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 02:25:24 -04:00
Cameron Cordes 6e5898e766 Unified search: rank within filtered set instead of pre-thresholding CLIP
When structured filters are present they're the constraint and CLIP only ranks
within the candidate set, so drop the global similarity threshold for that
case. Previously the 0.2 whole-library threshold ran BEFORE intersecting with
the filters, discarding filter-matching photos that scored just under it (e.g.
a 2022 beach photo at 0.18) — producing after_struct_filter=0 even when matches
existed. Plain semantic (no filters) keeps the user's threshold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 02:20:06 -04:00
Cameron Cordes 6c315edacc clip_client: log encode_text failures (URL + status/body or network error)
The CLIP encode failure reason was only ever returned in the HTTP response
body, never logged server-side, making 502s from /photos/search opaque. Log
the underlying cause — network error to the URL, or the Apollo HTTP status +
response body — so CLIP-service problems are diagnosable from the ImageApi log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 02:02:57 -04:00
Cameron Cordes 0a40e78528 Unified search: UNIFIED_SEARCH_MODEL env override for the translation step
Pin the NL->structured translation to a small, fast model that can stay
co-resident with CLIP (and the chat model) so it never evicts them on a tight
VRAM budget. Precedence: UNIFIED_SEARCH_MODEL env > client-selected model >
configured default. Logs the effective model (backend.model()) so model A/B
tests are visible. Documented in .env.example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 01:58:48 -04:00
Cameron Cordes e56235acc5 Unified search: stage-by-stage logging to debug empty results
Log the translated query (semantic/tags/place/date/media + has_struct), the
tag-filter file count, candidate-row + allowed-hash counts, and the CLIP
considered/hits/after-filter counts. Pinpoints which stage drops results to
zero (over-extracted filter, tag path mismatch, Any/All over-constraint, or
CLIP threshold). info-level for now while debugging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 01:29:21 -04:00
Cameron Cordes fcbd7e2733 Unified search: accept client model override (avoid model swapping)
Add an optional `model` query param to /photos/search/unified, passed into
resolve_backend's overrides. The client sends the user's currently-selected
local model so the translation step reuses an already-loaded model instead of
forcing a llama-swap eviction + cold start. Falls back to the configured
default when absent. Still local only (no hybrid).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 01:19:53 -04:00
Cameron Cordes e4c875f473 Unified NL search Phase 2: /photos/search/unified endpoint
Composes the two existing engines (Path A orchestration):
- Translate NL -> StructuredQuery via local LLM, respecting LLM_BACKEND
  (resolve_backend(Local) -> ollama or llama-swap; no hybrid).
- Forward-geocode the place name into a gps circle.
- Structured filters (tags/EXIF/geo/date/media) build a candidate set of EXIF
  rows; CLIP ranks within it, joined by content_hash. Degenerate cases match
  existing behavior: semantic-only -> plain CLIP; filters-only -> date-sorted.
- Echoes the interpreted query (incl. resolved place) for editable client chips.

Refactor: extracted reusable cores from clip_search (score_photos, resolve_hits,
parse_library_scope, score_error_response) shared by both endpoints. Removed the
Phase 1 allow-until-wired attributes now that nl_query + geo are consumed.

fmt + clippy clean; 23 backend tests pass (7 geo, 12 nl_query, 4 unified).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 01:03:43 -04:00
Cameron Cordes 50ed780844 Unified NL search Phase 1: NL→structured-query translator + forward geocoding
Foundation for the /photos/search/unified endpoint (Phase 2). Two new,
fully unit-tested pieces, not yet wired into a route (allow-until-wired,
mirroring llm_client.rs):

- ai/nl_query.rs: translate a free-text query into a StructuredQuery via one
  grounded LLM call. Two-stage — the model emits names/ISO dates, then a pure
  resolve step maps tag names against the real vocab and converts dates to
  unix seconds. Hallucinated (non-vocab) tags are surfaced in unmatched_tags
  rather than silently used as hard filters — the anti-noise guard. 12 tests.

- geo::forward_geocode + bbox_to_circle: resolve a place name to a circle via
  Nominatim /search, collapsing the bounding box to centroid + circumscribing
  radius so "Portland" and "Italy" both map onto the existing gps circle
  filter with no schema change. Radius is the max centroid-to-corner distance
  (corners aren't equidistant on a sphere). 4 tests.

fmt + clippy clean; 19 new tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 00:44:16 -04:00
Cameron Cordes 7e21213181 Reels: bound disk/ledger growth (pre-gen prune + on-demand cache sweep)
Nothing reaped reels before, so the on-disk cache and ledger grew
unbounded — each night's daily reel is a new ~4MB file + ledger row that's
stale within ~26h.

- Pre-gen self-prune: after recording a reel, prune_superseded keeps the
  newest PREGEN_KEEP_PER_SCOPE (2) rows per (span, library) and unlinks the
  superseded reels' mp4+sidecar. Caps the ledger/disk at ~spans×libraries×2.
- On-disk sweeper (spawn_reel_cache_sweeper): every 24h, removes reel mp4s
  with no ledger row and no live job older than REEL_CACHE_MAX_AGE_DAYS (7) —
  bounding the on-demand cache, which has no ledger row and otherwise grows
  forever — plus crashed-render cruft (.mp4.tmp/.concat.txt/orphan sidecars).
  Runs regardless of REEL_PREGEN_ENABLED; disable with REEL_CACHE_SWEEP_ENABLED=0.
- New DAO methods prune_superseded + all_cache_keys (with tests); env knobs
  documented in .env.example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:27:32 -04:00
Cameron Cordes 664b3694f8 Reels pre-gen: always render the agentic reel, don't adopt on-demand mp4
Past the key-aware dedup, any mp4 already at the cache key was not
pre-generated by us (no matching ledger row) — typically an on-demand
fast-scripted reel sharing the key after the max_segments alignment.
Adopting it recorded a ledger row pointing at the fast reel, silently
defeating agentic pre-gen. Drop the adopt-existing-mp4 shortcut and
always produce_reel (atomic overwrite). Worst case is one redundant
re-render if a prior run crashed between render and ledger write.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:16:14 -04:00
Cameron Cordes b52b1eb323 Reels pre-gen: make dedup cache-key-aware so key changes regenerate
exists_fresh only matched (span, library, render_version, age), so a
cache-key change that doesn't bump RENDER_VERSION (e.g. the max_segments
alignment, or any future selection-logic tweak) left last night's ledger
row looking 'fresh' — the nightly run would skip and the orphaned reel
would persist. Dedup now compares the stored cache_key to the freshly
computed key (and confirms the mp4 exists), so a changed key forces a
regen within the freshness window. exists_fresh stays as the HTTP
endpoint's fast gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:14:39 -04:00
Cameron Cordes 19fc1bbdf8 Reels pre-gen: use DEFAULT_MAX_SEGMENTS so cache keys match on-demand
pregen_one hardcoded max_segments: 24 while create_reel_handler defaults
to DEFAULT_MAX_SEGMENTS (40). Since the cache key encodes the raw
max_segments, the pre-generated reel's key never matched the client's
on-demand request, so POST /reels cache-hit an older max=40 reel and the
agentic pre-gen file was left orphaned. Align to DEFAULT_MAX_SEGMENTS (as
the plan specified) so the on-demand cache-hit path serves the pre-gen
reel. Content is unchanged — the actual beat count is duration-budgeted
either way; only the key descriptor differed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:12:54 -04:00
Cameron Cordes ca007a618d Reels pre-gen: record true media count + real upsert for user_ai_prefs
- pregen_one recorded media_count as planned.len() (beat count); record
  the actual media item total (media.len(), photos + clips) in both the
  cache-hit and freshly-rendered ledger paths. Drops the redundant
  photo_count binding.
- Replace upsert_prefs's insert-then-catch-error-then-update dance with a
  single atomic INSERT ... ON CONFLICT(id) DO UPDATE. Explicit id=1 makes
  the conflict target deterministic; explicit column .set((...)) keeps
  None -> NULL overwrite semantics so the row mirrors the latest request
  exactly, and genuine insert errors surface instead of being swallowed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:19:41 -04:00
Cameron Cordes e4d8d374fb Reels pre-gen: fix runtime breakers from review (1-5)
1. Drop the unregistered prefs_dao/reel_dao web::Data extractors from
   create_reel_handler / precomputed_reel_handler and read the DAOs off
   AppState instead (consistent with the scheduler). Missing app_data
   would have 500'd every POST /reels and /reels/precomputed at runtime.
2. Restore the dropped 'return' in the cache-hit branch — without it a
   cache hit fell through, overwrote the Done job with Queued, and
   re-ran the whole TTS+render pipeline on every request.
3. Make secs_until_next_run_hour minute/second-accurate so a batch that
   finishes inside the run hour sleeps ~24h instead of busy-looping
   (wake, re-run, sleep 0) for the rest of the hour. Tests updated.
4. Prune photo/user-bound tools (get_file_tags, get_faces_in_photo,
   recall_facts_for_photo, recall_facts_for_entity) from the agentic
   reel scripter's allow-list — they no-op/error with the empty
   file/user context and only burn iterations.
5. Align AGENTIC_SYSTEM_PROMPT's advertised tool list with the actual
   (pruned) allow-list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:14:36 -04:00
Cameron Cordes 5c9ee56527 Fix agentic reel audit issues: midnight bug, DAO wiring, dead code, DST timezone, validation
Blocking fixes:
- secs_until_next_run_hour: same-hour now returns 0 instead of 24h
- capture_prefs: called at both handler return points, never fails request
- capture_prefs: resolves library param, upserts to user_ai_prefs via DAO
- Scheduler: uses AppState DAOs instead of separate connections
- Pregen dedup: uses resolved library param instead of hardcoded 'all'
- run_readonly_tool_loop: added #[allow(dead_code)] (used in main.rs only)
- run_readonly_tool_loop: removed dead messages.push() call
- InsightGenerator: added exif_dao() getter for scheduler reuse

Medium fixes:
- Input validation: run_hour clamped 0-23, week_dow clamped 0-6
- DST-sensitive timezone: fixed_tz_offset() with env var config

Low fixes:
- Documented REEL_PREGEN_MAX_TOOL_ITERS and REEL_PREGEN_TZ_FIXED_MINUTES
- Removed dead test_app_state function and unused imports

Also fix: UpsertUserAiPrefs import path, chrono::Local::with_ymd_and_hms
requires TimeZone trait + .single(), unwrap_or_else closure simplification
2026-06-13 14:59:00 -04:00
Cameron Cordes f707353807 feat: nightly agentic pre-generation of memory reels
Implement end-to-end nightly pre-generation of memory reels with agentic
scripting that grounds narration in calendar, location, messages, and RAG.

Sections A-E from the plan:

A. Extract produce_reel pipeline core from run_reel_job with
   ScripterMode::Fast/Agentic and progress callbacks.

B. Agentic scripter: factor run_readonly_tool_loop from the insight
   generator, build read-only tool gate, prompt builder with GPS, and
   generate_script_agentic with fallback to fast path.

C. Precomputed reels ledger (SQLite table + DAO), GET /reels/precomputed
   handler with validity gate, GET /reels/by-key/{key}/video streaming,
   and normalize_library_key helper.

D. Nightly scheduler: spawn_pregen_scheduler with configurable hour,
   run_pregen_batch (day/week/month spans), pregen_one with dedup and
   disk-check, secs_until_next_run_hour time math.

E. user_ai_prefs passive mirror table + DAO for param capture in
   create_reel_handler and replay in the scheduler.

Also fixes resolve_library_param signature to take &[Library] and adds
resolve_library_param_state wrapper for AppState callers.

New files: migrations/2026-06-13-000000_add_precomputed_reels/,
  migrations/2026-06-13-000010_add_user_ai_prefs/,
  src/database/precomputed_reel_dao.rs,
  src/database/user_ai_prefs_dao.rs
2026-06-13 14:29:34 -04:00
Cameron Cordes b30c8c16d0 Reels: clips play through the beat instead of freezing early
A clip beat capped playback at CLIP_SECONDS and filled the rest of the
narration with a tpad freeze-frame, so a clip stopped dead on its last
frame for a second or two before the transition — a glitchy pause that
stills don't have. Extract clip_beat_plan: the clip now plays for as
much of its beat as the source footage covers, and we freeze only when
the source is genuinely shorter than the narration. Bump RENDER_VERSION.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 11:00:01 -04:00
Cameron Cordes f5581edf5e Reels: ease burst fade 0.08s → 0.12s
0.08s read as too abrupt; 0.12s keeps the burst clearly snappier than the
0.35s held-shot fade without jarring. Bumps RENDER_VERSION.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:07:41 -04:00
Cameron Cordes 65793a2dda Reels: mixed-media (video clip beats) + faster burst fade
Videos in a span now appear as clip beats: the first few seconds of the
video (capped at CLIP_SECONDS=5, and to the source length) filled to the
portrait canvas like photos, with its live audio ducked under the
narration (amix at 0.35). If the narration outlasts the clip, the last
frame is held (tpad); clips with no audio track just play under narration.

Selection splits the beat budget between photo beats and clip beats —
clips get up to half (≥1 when present), photos the rest — then merges
both back into chronological order. SegmentMedia gains a Clip variant;
beats carry `media` (photos or one clip) and the cache key tags P/C so a
path used as a still vs a clip differ.

Also drops the burst fade from 0.15s to 0.08s so a quick burst reads
clearly differently from a held shot. Bumps RENDER_VERSION.

The clip filtergraph (fill + duck-mix + last-frame hold) is unit-tested
but, like the rest of the ffmpeg path, wants a real render check on the
GPU host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:02:51 -04:00
Cameron Cordes 299e32b014 Bump version to 1.4.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:45:24 -04:00
Cameron Cordes 6e90f24307 Reels: burst beats + duration budget for week/month, plus step logging
Restructures a reel around beats — one narration line over one or more
photos — instead of one line per photo. A single-photo beat is a held
shot; a multi-photo beat is a quick burst that flashes through several
moments of an event while the line is read. So a week/month reel can show
everything it spans without a narrated (and timed) segment per photo.

Selection (selector.rs):
- Duration budget: cap the number of narrated beats to ~REEL_TARGET_SECONDS
  (default 90, env-tunable) so week/month reels don't run minutes long.
- Event clustering by time gap; when there are more events than the beat
  budget, adjacent events merge so the whole span stays covered. Each beat
  bursts up to MAX_BURST_PHOTOS (an even spread), so a 40-shot dinner
  contributes a handful of quick frames, not forty narrated seconds.

Render (render.rs): a beat renders its photos as a concat of per-photo
fills (blurred-bg portrait, fps-before-fade) under one muxed narration;
burst photos get a snappier fade. beat_durations splits the narration
across the photos, stretching only if a long burst would flash too fast.

Adds high-level info logs across the steps (request → script → per-beat
narrate/render → join → done with elapsed) for visibility. Bumps
RENDER_VERSION to re-render cached reels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:43:18 -04:00
Cameron Cordes 740fc4d841 Reels: fix steppy fade (fps before fade) and ease the expression bump
The fade looked steppy/low-frame-rate because the filtergraph normalized
fps AFTER the fade filters: the brightness ramp was sampled at the looped
still's coarse input cadence, then duplicated up to 30fps. Move fps ahead
of the fades, pin the still's input framerate (-framerate), and force CFR
output (-r) so the dip ramps across a full 30 frames and plays steadily.

Ease narration expressiveness from 0.7 to 0.6 (still tunable via
REEL_TTS_EXAGGERATION). Bump RENDER_VERSION so existing reels re-render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:20:52 -04:00
Cameron Cordes 7715a7a905 Reels: portrait canvas with blurred fill, fade transitions, warmer TTS
Fixes the "image is tiny" problem: a 1920x1080 landscape reel letterboxes
to a ~25%-height band on a portrait phone. Switch to a portrait 1080x1920
canvas and fill it per photo with a blurred, zoomed copy of the image
behind the sharp fitted photo — so the frame is always full regardless of
the photo's orientation, with no black bars and no cropping of the subject.

Add a quick 0.35s fade in/out baked into each segment so concatenated
photos dip smoothly instead of hard-cutting (fade-out lands in the
narration's silent tail, so speech isn't clipped). Drop the unused
Ken Burns branch — motion can return deliberately later.

Warm up the narration a touch: thread Chatterbox's `exaggeration` through
synthesize_serialized and default reels to 0.7 (tunable via
REEL_TTS_EXAGGERATION). Bump RENDER_VERSION so existing landscape reels
re-render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:10:26 -04:00
Cameron Cordes 42453d5786 Fix reel concat: force -f mp4 for the .tmp output path
The concat stage wrote to <key>.mp4.tmp (for an atomic publish-rename),
but ffmpeg infers the muxer from the output extension and can't map
.tmp to a format — "Unable to choose an output format". Force the mp4
muxer explicitly so the temp extension is irrelevant. Segment render,
NVENC, TTS, and scripting were already working end-to-end; this was the
only failure, at the final join.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:56:48 -04:00
Cameron Cordes e3f731b3b2 Add memory-reel backend: on-demand narrated photo slideshow
New POST /reels + GET /reels/{id} (+ /video) build an MP4 slideshow of a
memory span (day/week/month), narrated by the LLM in a cloned voice.

Pipeline (src/reels/): a selector resolves which photos + reel metadata,
the scripter writes one narration line per photo via a single LLM call
(reusing each photo's cached insight as context — no fresh vision calls,
so reel generation stays off the GPU's vision slot), each line is
synthesized to speech, and the renderer assembles stills + narration via
ffmpeg. Jobs run in the background (mirroring the TTS speech-job
registry) since a reel takes minutes; the finished MP4 is cached on disk
keyed by the selection so a repeat request is instant.

The segment model is media-typed (Photo today) so a video-clip segment
(phase 2) and a nightly pre-render (phase 3) slot in without reworking
the pipeline. Ken Burns motion is implemented but defaulted off pending a
visual check on the GPU box.

Supporting changes:
- memories: extract gather_memory_items() so the reel selector reuses the
  exact window/exclusion/tz/sort logic behind /memories.
- ai::tts: add synthesize_serialized() so reel narration honors the same
  single-GPU permit + write lease as user TTS requests.
- video::ffmpeg: make get_duration_seconds() pub for narration timing.
- AppState: reels_path (REELS_DIRECTORY, defaults beside preview clips).

Pure logic (cache key, script parsing, ffmpeg arg/filter construction,
even sampling, segment timing) is unit-tested (26 tests). The runtime
path (ffmpeg render, TTS, LLM) needs a real run on the GPU host to verify
end-to-end — not exercisable in CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:31:08 -04:00
cameron 98274c3301 Merge pull request 'Feature/tts voice management' (#105) from feature/tts-voice-management into master
Reviewed-on: #105
2026-06-13 02:01:37 +00:00
Cameron Cordes 1017fe73af Include start offset in voice-name window tag
Clones that don't start at 0:00 are tagged with where the reference
window begins (grandma-at1m32s-30s), so voices cloned from different
sections of the same source are distinguishable in the voice list.
Zero-start names keep the existing -30s form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:21:41 -04:00
Cameron Cordes 1dec34540d Add start/duration window selection for voice-clone reference clips
Both voice creation endpoints (upload + from-library) now accept optional
start_seconds/duration_seconds, threaded to ffmpeg as -ss/-t, so the
reference window can target clean speech anywhere in a long recording
instead of always the first N seconds. Duration is clamped to the
LLAMA_SWAP_TTS_REF_SECONDS cap and the voice-name tag reflects the
actual window length.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:09:03 -04:00
Cameron Cordes 2e0f78aa1b Add user-configurable TTS pronunciation overrides
A JSON map (TTS_PRONUNCIATIONS_PATH, default tts_pronunciations.json)
rewrites mispronounced words — place names, initialisms, dotted
abbreviations — to phonetic spellings before synthesis, applied after
markdown cleanup in both /tts/speech paths. Whole-word smartcase
matching (lowercase keys match any casing, uppercase keys exact),
longest key wins, hot-reloaded on mtime change with last-good fallback
on parse errors. See tts_pronunciations.example.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:06:18 -04:00
Cameron Cordes 3fa4fa8501 Strip markdown decoration from model-emitted insight titles
Models wrap the title line despite the prompt — "**Title: A Day in the
Woods**", "## Title: ...", bold around just the label — which made
parse_title_body's bare "Title:" prefix match fall through to the
fallbacks and leak asterisks into the stored title.

strip_title_markdown trims bold/italic markers, heading hashes,
backticks, and quotes from both ends; applied to the label line, the
extracted title, both fallback paths, and generate_photo_title (which
previously stripped only quotes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:18:43 -04:00
Cameron Cordes efd05db523 Make the embedding model swappable via env for A/B testing
Trialing Qwen3-Embedding-0.6B (1024-dim, instruct-prefixed queries)
against nomic required code changes at every hardcoded seam; now it's a
config flip plus a reembed_embeddings run.

- EMBEDDING_DIM env (default 768) replaces every hardcoded dim check:
  daily summary / calendar / search / location DAOs, Ollama batch
  validation, reembed_embeddings
- entities gains the dim guard it never had — a wrong-dim vector
  silently kills dedup/recall (cosine over mismatched lengths is 0),
  so store None and warn instead
- embed_query / embed_document split with EMBED_QUERY_PREFIX /
  EMBED_DOCUMENT_PREFIX (literal \n expanded): retrieval models treat
  the two sides differently — nomic wants search_query:/search_document:,
  Qwen3 wants Instruct:...\nQuery: on queries only. All query-side
  call sites and all corpus writers now declare their side.
- document the contract in CLAUDE.md: change the model or any of these
  vars → re-run reembed_embeddings or search is garbage

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:40:40 -04:00
Cameron Cordes b1493f5aca Wait out TTS GPU hold before the insight job timeout starts
The GPU lease keeps per-request reqwest budgets from burning behind a
cross-model swap, but the job-level INSIGHT_GENERATION_TIMEOUT_SECS
wall-clock started at spawn — an insight queued behind a running TTS
synthesis parked its first chat call on the lease and timed out
("timeout after 180s") before chatterbox even finished loading.

Acquire-and-drop an LLM read lease before starting the job clock in
both insight handlers: the wait for the GPU happens before the
timeout begins, mirroring the per-request lease semantics. Dropped
immediately — holding it across the generation would deadlock the
chat calls' own lease acquisitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:15:38 -04:00
Cameron Cordes a022a3d15d Fix RAG vector-space mismatch and search_rag retrieval quality
Queries embedded via llama-swap were searching corpora embedded via
Ollama (measured: spaces diverged). Introduce LocalLlm — the local
Ollama + llama-swap pair with LLM_BACKEND dispatch baked in — and route
all embedding writers through it; anything embedding via a concrete
client reintroduces the bug.

- search_rag: embed the model's query verbatim (no metadata boilerplate),
  make date optional — no time-decay when omitted, so "when did X
  happen?" queries rank purely by similarity across all time
- reembed_embeddings bin: re-embed summaries / calendar / search /
  knowledge entities via the active backend, with old-new cosine report
  per table and truncate-and-retry for inputs over the embed server's
  physical batch size
- import_calendar, import_search_history: embed through LocalLlm
- search_messages / get_sms_messages: render sender → recipient so sent
  messages are attributable to a conversation
- insight job failures: store the one-line anyhow context chain ({:#})
  instead of the Debug dump the client was shown verbatim
- serialize env_dispatch tests behind a lock (parallel-runner flake)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:06:52 -04:00
Cameron Cordes 0accc4ef2f Add GPU lease coordinating LLM and TTS requests through llama-swap
llama-swap runs chat/vision/Chatterbox as a mutually-exclusive set on
one GPU and HOLDS a request for a non-resident model until the resident
model drains, then swaps. That hold burned the holder's reqwest timeout
(measured: a queued TTS lost 77s behind one LLM turn; an LLM request
behind a synthesis waited the entire remaining synth), so concurrent
insight + read-aloud timed out instead of queueing.

ai::gpu adds a fair RwLock lease acquired before each request is sent,
so cross-model waits happen before the HTTP timeout starts: chat/vision
share the read lease, TTS synthesis and voice-library ops (which spin
Chatterbox up) take the write lease, and embeddings take none (the
embed slot is in llama-swap's always-resident group). Speech jobs now
flip queued->running only after acquiring the GPU, letting the client
anchor its poll deadline to that transition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:20:06 -04:00
Cameron Cordes 03699f7413 Add TTS voice deletion, async speech jobs, voice-list cache, ref-seconds name tags
- DELETE /tts/voices/{name}: remove a cloned voice via the llama-swap
  passthrough (upstream chatterbox-tts-api exposes DELETE /voices/{name}).
- POST/GET/DELETE /tts/speech/jobs: durable job flow for long syntheses —
  dispatch returns 202 + job id, the synth queues on the GPU permit instead
  of fast-failing 429, and clients poll for the result (kept ~10 min).
- GET /tts/voices now serves an in-memory cache so listing voices doesn't
  make llama-swap spin up the TTS model (evicting the resident LLM);
  invalidated on create/delete, ?refresh=1 forces an upstream re-query.
- Created voice names are tagged with LLAMA_SWAP_TTS_REF_SECONDS (e.g.
  grandma-30s) so the library shows which ref length produced each clone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:36:15 -04:00
cameron c78e751743 Merge pull request 'Feature/insight history' (#104) from feature/insight-history into master
Reviewed-on: #104
2026-06-10 19:01:14 +00:00
Cameron Cordes 31904fef80 Raise chat truncation default num_ctx to 32k, env-overridable
The history-truncation budget assumed an 8192-token context whenever a
chat request omitted num_ctx, while the llama-swap chat slots serve
20k-131k. Replayed transcripts past ~6k tokens were silently gutted
every turn — losing conversation history and destroying llama.cpp
KV-cache prefix reuse (full SWA re-prefill per turn).

Default is now 32768 (real conversations top out around 16k), with
AGENTIC_CHAT_DEFAULT_NUM_CTX to override per deploy, floored at
headroom + 1024. Explicit per-request num_ctx still wins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:14:02 -04:00
Cameron Cordes 13f3635db2 Fix clippy lints in backfill and libraries tests
Keep `cargo clippy --tests` clean alongside the agentic-loop changes:
alias backfill's five-element setup() tuple as SetupFixture
(type_complexity) and build the single-library health map via
std::slice::from_ref instead of cloning (unnecessary clone-to-slice).
No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 18:29:44 -04:00
Cameron Cordes b711252c23 Resolve persona prompts server-side; drop synthetic prompt in chat_turn
A request carrying persona_id but no system_prompt used to fall back to
the neutral default voice. Both agentic generation
(generate_agentic_insight_handler) and chat bootstrap now resolve the
persona's stored prompt from the persona store, with precedence:
explicit non-blank client system_prompt > persona store lookup >
existing default ("default" persona id behaves the same — used if the
store has a row, neutral default otherwise). Resolution happens at the
handler / bootstrap entry where the DAO is reachable; internals are
unchanged. resolve_bootstrap_system_prompt takes the resolved persona
prompt as a second argument, with precedence tests.

Also in insight_chat:

- Sync chat_turn no longer persists the synthetic "Please write your
  final answer now without calling any more tools." user message pushed
  on iteration exhaustion — extracted both streaming variants'
  synthetic_idx pattern into push/remove_synthetic_final_prompt (the
  remove is a defensive no-op on index drift) and applied it to all
  three loops; round-trip test included.
- Strip leaked <think> blocks from the final content persisted as the
  reply in chat_turn and both streaming AgenticLoopOutcomes (mid-stream
  TextDeltas are untouched; the raw transcript keeps the block).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 18:29:35 -04:00
Cameron Cordes 091982bdfc Add recall_facts_for_entity tool; fix generation gates and tool output
Agentic-loop fixes in the generator:

- New recall_facts_for_entity tool (always-on, like recall_entities):
  fetches facts for one entity by id so the model can follow up on
  entities surfaced by recall_entities that aren't photo-linked
  (recall_facts_for_photo only covers linked entities). Mirrors that
  tool's persona scoping (PersonaFilter::Single) and the persona's
  reviewed_only_facts filter exactly, and renders in the same
  "Entity: ... / - predicate object" style. Wired through execute_tool
  and the trajectory summarizer.
- Generation now resolves gates persona-aware:
  current_gate_opts_for_persona(images_inline, Some((user_id,
  persona_id))) instead of the None-defaulting wrapper, so a persona's
  allow_agent_corrections opens propose_correction during generation the
  same way chat turns already did. The now-unused current_gate_opts
  wrapper is removed.
- Strip leaked <think> blocks from the final assistant content before
  parse_title_body / store_insight (raw training transcript keeps them).
- Honest truncation labels: get_sms_messages and get_location_history
  said "Found N ..." while listing only the first K; found_header now
  emits "Found N ... (showing first K):" when truncated, and the
  summarizer still parses the count.
- Clamp days_radius in get_calendar_events and get_location_history to
  1..=30, matching get_sms_messages.
- persona_system_prompt helper (persona store lookup, blank-prompt ->
  None) for server-side persona resolution; callers land in the next
  commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 18:29:20 -04:00
Cameron Cordes 592dfcb42c Accumulate streamed tool calls across chunks in Ollama streaming
Ollama >=0.8 can stream tool_calls incrementally across NDJSON chunks;
chat_with_tools_stream did `tool_calls = Some(tcs)` per chunk, so only
the last chunk's calls survived assembly and earlier calls were silently
dropped. Append into the accumulator instead.

- ollama: append_streamed_tool_calls helper + tests covering two calls
  arriving in separate chunks and the single-chunk batch case.
- llamacpp: the SSE delta assembly was already correct (per-index
  BTreeMap, same-index argument fragments concatenate, distinct indexes
  accumulate); extracted it into apply_tool_call_deltas /
  finalize_tool_calls and added tests pinning that behavior.
- llm_client: new shared strip_think_blocks (moved from ollama's private
  extract_final_answer, which now delegates) so the tool-calling final
  content paths can reuse it; unit tests for tagged/plain/unclosed/empty
  cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 18:29:06 -04:00
Cameron Cordes 8e4f91561b Add per-file insight history endpoint and rate-by-id
Expose GET /insights/history?path=... returning every generated version
of a photo's insight (current plus superseded), newest-first, backing the
mobile per-file insight history view.

- New get_insight_history_handler; reuses the existing get_insight_history
  DAO method (removed its dead_code allow).
- impl From<PhotoInsight> for PhotoInsightResponse, collapsing the mapping
  that was duplicated across the single-get and all-insights handlers.
- rate_insight_by_id DAO method + optional insight_id on RateInsightRequest
  so previously generated versions can be approved/rejected (the path-based
  rate only touches the current row).
- DAO tests for history ordering/scoping and id-targeted rating.
- cargo fmt normalized a multi-line assert in insight_chat.rs tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:28:22 -04:00
cameron 750a8de6b1 Merge pull request 'Feature/tts integration' (#103) from feature/tts-integration into master
Reviewed-on: #103
2026-06-07 21:35:49 +00:00
Cameron Cordes 412da2ce8e Collapse blank lines to a single break in TTS text cleaning
Chatterbox inserts a long pause — sometimes ~20s of silence — for each
blank line it sees, and insight text is markdown full of paragraph
breaks. clean_for_tts previously preserved paragraph structure
(\n{3,} -> \n\n), so every paragraph boundary still reached the model
as a double newline. Now any run of 2+ newlines, including
whitespace-only blank lines, collapses to a single newline so the
worst pause a break can cause is a normal line-break pause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:12:43 -04:00
Cameron Cordes dec6f21af9 Bump version to 1.3.0
TTS feature release: /tts/speech + voice library endpoints (Chatterbox via
llama-swap), input cleaning, tuning knobs, WAV-normalized voice cloning,
OTel spans, dedicated synth timeout, and single-flight serialization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:07:10 -04:00
Cameron Cordes cab867da60 Serialize /tts/speech with a single permit; 429 when busy
The Chatterbox wrapper has no internal lock or cancellation, so concurrent
synth requests contend on the single GPU and abandoned (timed-out) jobs
cascade into stacked slowness. Gate synthesis behind a one-permit semaphore
and fast-fail concurrent requests with 429 instead of queueing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:02:56 -04:00
Cameron Cordes d8dd260c6b Give TTS synthesis its own (longer) request timeout
Long insights are chunked + synthesized server-side and can run past the shared
180s chat/embedding client timeout, causing spurious timeouts. /tts/speech now
uses a per-request timeout from LLAMA_SWAP_TTS_REQUEST_TIMEOUT_SECONDS
(default 600), overriding the client default without affecting chat/embeddings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:25:06 -04:00
Cameron Cordes 9978b28b52 Document TTS endpoints + env in CLAUDE.md
Sync CLAUDE.md with the Chatterbox TTS feature: the /tts/* endpoints and the
LLAMA_SWAP_TTS_MODEL / _VOICE / _REF_SECONDS env vars (only need LLAMA_SWAP_URL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:15:39 -04:00
Cameron Cordes ccacfe1113 Instrument TTS handlers with OTel spans (codebase standard)
Each /tts handler now opens an http.tts.* span via extract_context_from_request
+ global_tracer().start_with_context, sets Status::Ok / Status::error on every
outcome, and records useful attributes (model, format, voice_name, byte counts)
— matching the insight handlers. Prometheus request metrics were already
covered by the app-wide actix-web-prom middleware.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:10:43 -04:00
Cameron Cordes 62d517dcda Normalize voice-clone reference audio to WAV via ffmpeg
Chatterbox validates the reference clip by file extension and rejects formats
like .aac/.opus. Always transcode the reference (upload bytes and library
files alike) to mono 24 kHz WAV with ffmpeg before forwarding, so any source
format is accepted and the from-library audio/video paths are unified.

The reference length cap is now configurable via LLAMA_SWAP_TTS_REF_SECONDS
(default 30) — Chatterbox is zero-shot, so a clean ~10-20s clip is the sweet
spot. Drops the now-unused mime guesser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:50:08 -04:00
Cameron Cordes 35c5ecb427 Document TTS endpoints and env in README + .env.example
Adds the /tts/speech and /tts/voices* endpoints plus LLAMA_SWAP_TTS_MODEL /
LLAMA_SWAP_TTS_VOICE (TTS only needs LLAMA_SWAP_URL, not LLM_BACKEND=llamacpp).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:34:34 -04:00
Cameron Cordes 51be5df214 Clean insight text for TTS and pass through Chatterbox tuning knobs
/tts/speech now normalizes input before synthesis: unwraps markdown
links/images to visible text, drops heading/list/blockquote/emphasis
markers and URLs, strips emoji (which non-turbo Chatterbox mispronounces
or skips), and collapses whitespace. Centralized in clean_for_tts so the
app, WebUI, and curl all get clean audio. Bracketed tags are deliberately
preserved for a future Turbo (paralinguistic) switch.

Adds optional exaggeration / cfg_weight / temperature to the request,
clamped to Chatterbox's documented ranges and forwarded on the speech
body. Unit tests cover markdown/emoji/URL stripping and tag preservation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:15:05 -04:00
Cameron Cordes 69268d03fe Add TTS endpoints backed by Chatterbox via llama-swap
LlamaCppClient gains text_to_speech (OpenAI /audio/speech), list_voices and
create_voice (voice library at the swap-root /upstream/<model>/voices
passthrough), plus a tts_model slot configured via LLAMA_SWAP_TTS_MODEL
(default "chatterbox").

New Claims-gated routes:
- POST /tts/speech        -> { audio_base64, format } for data: URI playback
- GET  /tts/voices        -> voice library passthrough
- POST /tts/voices/upload -> clone a voice from an uploaded clip (multipart)
- POST /tts/voices/from-library -> clone from a library file (ffmpeg-extracts
  audio from video; audio forwarded as-is)

Security: voice_name sanitized to [A-Za-z0-9_-] (it becomes an upstream
filename), 25 MB upload cap, library refs restricted to real audio/video,
path confined via is_valid_full_path. Adds is_audio_file + unit tests for the
sanitizer, mime guesser, and swap-root derivation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:04:42 -04:00
cameron 015dc976e3 Merge pull request 'feature/insight-jobs' (#102) from feature/insight-jobs into master
Reviewed-on: #102
2026-06-02 23:41:36 +00:00
Cameron Cordes b9b6e51af1 Stop ffprobe walking every frame in video stream probe
probe_video_stream_meta requested a bare `side_data_list` section in
-show_entries. On modern ffprobe that's the *frame* side-data section,
so ffprobe enumerated every frame to collect it — reading the entire
mdat. For non-faststart phone clips on the SMB mount this turned a
metadata probe into a full-file read: /video/generate took 10-32s per
open (0% CPU, time proportional to file size).

Switch to `stream_side_data_list`, which reads the Display Matrix
rotation from the stream header (moov) without touching frames. Codec,
frame rate, and rotation are unchanged; the existing rotation parser
already reads streams[0].side_data_list[].rotation. Fixes both the
open-path probe and the transcode actor's probe. Cold opens now return
near-instantly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 13:19:47 -04:00
Cameron Cordes 16ae82ba70 Normalize video rel_path lookup to forward slashes on Windows
generate_video built the rel_path for its image_exif lookup by stripping
the library root from the absolute path, leaving backslashes on Windows
(Melissa\clip.mp4). file_scan stores rel_paths forward-slash and
get_exif_batch matches exactly with no normalization, so the lookup
missed and the handler re-hashed the entire video file on every request.

Extract rel_path_for_lookup and normalize separators with replace('\\',
'/'). Adds tests for Windows/Unix separators, file-at-root, leading
separator stripping, and the no-match fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 12:51:44 -04:00
Cameron Cordes a542ea411b Exclude inlined image bytes from chat context budget
The truncation budget estimated message size by serializing the full
ChatMessage array, including the base64 image persisted in the first
user message. A 1024px JPEG is hundreds of KB of base64 characters —
8-19x the entire ~24KB text budget at the default num_ctx — and the
image lives in the protected prefix that's never dropped. The budget
check was therefore essentially always over, dropping all tool history
and firing the "trimmed context" banner on every turn for vision
backends that inline images.

estimate_bytes now strips image payloads before counting and charges a
flat IMAGE_TOKENS_EACH per image instead, so the budget reflects real
text token pressure. Adds a regression test covering a short
conversation with one large image.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 11:51:57 -04:00
Cameron Cordes 962f7bf05c Add reconnectable async chat-turn flow with in-memory TurnRegistry
Replace the one-shot SSE chat stream with an async dispatch + reconnectable
replay flow so the mobile client survives backgrounding, network blips, and
OS-killed sockets without losing an in-flight agentic turn.

- TurnRegistry/TurnEntry: in-memory per-turn event buffer (cap 500, front
  eviction) shared by the agentic loop (writer) and SSE replay readers.
  ReplayOutcome + replay_from/next_batch distinguish Events/CaughtUp/Gone;
  next_batch registers the Notify before reading state (no lost wakeup) and
  drains every buffered event before signaling terminal, so the final
  Done/Error is never dropped and the stream closes cleanly.
- Endpoints: POST /insights/chat/turn (202 + turn_id), GET
  /insights/chat/turn/{id} (SSE replay, ?skip_before= resume, per-event seq,
  410 on eviction), DELETE /insights/chat/turn/{id} (real task abort +
  cooperative is_running() check at each loop boundary).
- Cancellation actually stops the task (AbortHandle stored on the entry) and
  emits a Done{cancelled:true}; callers skip persistence on cancel.
- Background sweeper drops stale turns; interval clamped to <=300s.
- OpenTelemetry spans: ai.chat.turn.execute/replay/cancel.
- Legacy POST /insights/chat/stream path preserved unchanged.

Tests: registry coverage for terminal delivery (race guard), waiting, Gone,
abort, eviction; handler integration tests for 404/410, skip_before, seq
stamping, completed replay, and cancel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:50:25 -04:00
Cameron Cordes 0c1c1c6792 fix: split token count columns into separate migration
A previous commit added prompt_eval_count and eval_count to the
existing 2026-05-27-000002_add_insight_generation_params migration,
but Diesel won't re-run an already-applied migration. Environments
that applied the original version of 000002 never got these two
columns, causing "no such column: photo_insights.prompt_eval_count"
on every insight read.

- Revert 000002 up.sql to its original 7-column form
- Add 000003_add_insight_token_counts for the two missing columns

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:34:44 -04:00
Cameron Cordes cdd981fe64 fix: inline DB error source into DbError struct
The previous fix logged the underlying error in a separate log line,
but the error that propagated up still showed just "DbError { kind:
InsertError }" at the call site. Now the source message is captured
on the struct itself, so Debug/Display output at any call site shows
the actual Diesel error inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:30:19 -04:00
Cameron Cordes dad0220587 fix: stop swallowing DB errors across the entire DAO layer
Every map_err(|_| DbError::new(...)) and map_err(|_| anyhow!("..."))
in the database layer was discarding the actual Diesel/SQLite error,
making failures impossible to diagnose from logs.

- Add DbError::log() that logs the source error before converting
- Replace all ~130 swallowed outer map_err closures with DbError::log
- Replace all ~47 swallowed inner anyhow closures to include the
  source error in the message

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:56:48 -04:00
Cameron Cordes 39ad83f55b fix: surface actual Diesel error in store_insight instead of generic InsertError
The previous map_err closures discarded the Diesel error, making
failures like missing columns impossible to diagnose from logs.
Now the underlying error is logged before converting to DbError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:53:54 -04:00
Cameron Cordes 9654d256f4 fix: persist token counts and fix agentic insight_id mapping
- Add prompt_eval_count and eval_count columns to photo_insights so
  token usage from llama-swap/Ollama is stored and returned by the API
- Fix agentic generator return: was (prompt_eval_count, eval_count),
  handler destructured first element as insight_id — now returns
  (insight_id, prompt_eval_count, eval_count)
- Wire prompt_eval_count/eval_count from DB into PhotoInsightResponse
  instead of hardcoded None

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:47:57 -04:00
Cameron Cordes 449ce1fda1 chore: resolve all clippy warnings and formatting
- Replace impl ToString with impl Display for InsightJobStatus and
  InsightGenerationType
- Rename from_str → parse to avoid confusion with std::str::FromStr
- Collapse nested if statements (handlers, insight_chat, insight_generator,
  image handlers)
- Use is_multiple_of() instead of manual modulo checks
- Suppress deprecated diesel::dsl::count_distinct (no drop-in replacement
  available in current Diesel version)
- Scope MutexGuard in synthesize_merge to drop before await
- Allow dead_code on generate_no_think, enumerate_indexable_files,
  total_deleted (intended for future use)
- Allow type_complexity on Diesel query result tuples

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:13:48 -04:00
Cameron Cordes a410683edf fix: fail fast when LLM_BACKEND=llamacpp but LlamaCppClient is unconfigured
Previously embed_one() silently fell back to Ollama embeddings,
which would load nomic-embed-text into VRAM alongside llama-swap —
wasting memory on an unintended model. Now returns an error with
an actionable message instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:02:42 -04:00
Cameron Cordes 2818936739 fix: audit fixes for async insight jobs + persist generation params
- Fix query param mismatch: rename GenerationStatusQuery.file_path to
  path so the client's app-resume buildQuery({ path: ... }) resolves
  correctly instead of always getting 400
- Remove dead _lib_id bindings from both generate handlers
- Return 202 Accepted instead of 200 from generate endpoints
- Restore OpenTelemetry span instrumentation on generate handlers
- Remove stale UNIQUE constraint from initial migration (incompatible
  with plain-INSERT DAO)
- Add tests for status guard: complete_job/fail_job are no-ops when
  job is already cancelled, and cancel_job by id
- Persist generation params (num_ctx, temperature, top_p, top_k, min_p,
  system_prompt, persona_id) on the photo_insights table for auditing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:02:15 -04:00
Cameron Cordes b87eb4e690 feat: async insight generation with SQLite job tracking
- Add insight_generation_jobs table migration and DAO
- Implement job lifecycle: create_or_get_active, complete, fail, cancel
- Refactor POST /insights/generate and /agentic to async spawn with timeout
- Add GET /insights/generation/status endpoint with job_id and file_path lookup
- Use String for enum fields in Diesel models to avoid private Bound type
- Add from_str() helpers on InsightJobStatus and InsightGenerationType
- Fix update_training_messages to return Result<usize, DbError>
- 7/7 DAO unit tests passing
2026-05-27 10:02:18 -04:00
cameron 5a75d1a28c Merge pull request 'feature/llamacpp-backend' (#101) from feature/llamacpp-backend into master
Reviewed-on: #101
2026-05-26 18:58:47 +00:00
Cameron Cordes b03ee60342 fix: prevent hybrid mode from leaking OpenRouter model to local llamacpp client
When backend=hybrid with LLM_BACKEND=llamacpp, the user-selected model
(an OpenRouter id like "google/gemini-3-flash-preview") was being applied
to the local LlamaCppClient's primary_model and vision_model. This caused
describe_image to send the OpenRouter model name to llama-swap, which
returned 400 because it has no such slot.

Guard the local-client model override with !is_hybrid so it only applies
in local-only mode (where the user is selecting a different local model).
Bump to v1.2.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 09:55:16 -04:00
Cameron Cordes 0a627f4880 Add contact name filter to SMS search tool + misc improvements
- sms search tool: accept contact name, trim/validate, skip when
  contact_id is set, pass to API client
- sms_client: new contact field in SmsSearchParams, URL-encode on wire
- Tool description clarifies contact_id takes precedence when both given
- Add parse_title_body helper for LLM response parsing
- llamacpp backend improvements
2026-05-25 21:46:18 -04:00
cameron b9175e2718 image: add xlarge (4096px) on-demand preview tier
New `PhotoSize::XLarge` variant sits between `Large` (2048px) and
`Full` (original). On-demand generated and disk-cached at
`_xlarge/<hash>.jpg`, same waterfall as `Large` (embedded RAW preview
→ ffmpeg → image crate). Sources below 4096px serve at native size.

Reduces decoded bitmap memory from ~192MB (48MP full) to ~64MB for
the mobile viewer's zoom tier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:33:03 -04:00
Cameron Cordes 9dba659d1e test: add llamacpp model-slot consistency and content-null tests
Cover the properties that prevent mid-turn model swaps in llama-swap
exclusive mode: vision_model defaults to primary, cloned local client
mirrors the user-selected model, embeddings stay on their own slot.
Also test the content:null serialization for tool-calling messages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:29:51 -04:00
Cameron Cordes 208344ad98 ai: mirror chat model on local client to prevent mid-turn model swap
When the user selects a model from the picker, the local client's
primary_model and vision_model now match the chat model. Prevents
llama-swap exclusive mode from swapping models when describe_photo
or rerank fires during an agentic turn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:27:29 -04:00
Cameron Cordes fb388c29d7 docs: update env + CLAUDE.md for direct-vision llamacpp + ResolvedBackend
llamacpp models now receive images directly instead of
describe-then-inline. LLAMA_SWAP_VISION_MODEL defaults to the
primary model. Document the ResolvedBackend dispatch pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:03:12 -04:00
Cameron Cordes a8a661f70a ai: extract ResolvedBackend, remove ~480 lines of duplicated dispatch
Replace 5 copies of the ~80-line backend resolution pattern with a
single InsightGenerator::resolve_backend() builder that returns a
ResolvedBackend (chat + local clients, BackendKind enum, images_inline
flag). Tool dispatch now takes &ResolvedBackend instead of
&OllamaClient + model + backend strings.

Remove duplicated ollama/openrouter/llamacpp fields from
InsightChatService — InsightGenerator owns them and resolve_backend
uses them. Delete build_chat_clients (replaced by resolve_backend).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:00:50 -04:00
Cameron Cordes 0631820fbf ai: send images directly to llamacpp chat models + add ResolvedBackend
llamacpp models now receive images via OpenAI content-parts instead of
the describe-then-inline strategy (hybrid mode unchanged). Fixes
assistant messages with tool_calls emitting content: null instead of ""
to satisfy strict Jinja template role-alternation checks. Adds debug
logging of message role sequences on llamacpp requests.

Introduces BackendKind enum, SamplingOverrides, and ResolvedBackend in
a new backend.rs module. InsightGenerator::resolve_backend centralises
client construction + vision capability detection — next step wires the
existing inline dispatch through it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:00:37 -04:00
Cameron Cordes be51421b38 ai: collapse llamacpp into LLM_BACKEND env switch
Reverts the per-request backend="llamacpp" value. Chat/vision/embedding
backend is now a deploy-time decision (LLM_BACKEND=ollama|llamacpp),
applied globally across chat, vision describe, and embeddings — so
embedding vectors stay in one space across the index.

- Per-request backend whitelist back to "local"|"hybrid". A request
  arriving with backend="llamacpp" is rejected.
- LLM_BACKEND=llamacpp swaps the entire local stack to llama-swap:
  chat hits the chat slot, describe hits the vision slot, embeddings
  hit the embed slot. Hybrid mode still routes chat to OpenRouter
  but uses LLM_BACKEND for the describe pass.
- Drops env vars HYBRID_VISION_BACKEND, LLAMA_SWAP_VISION_MODELS,
  EMBEDDING_BACKEND (the last never shipped). Drops the
  LlamaCppClient.vision_models allowlist — capability inference now
  reports has_vision only for the configured vision_model slot.
- Drops the /insights/llamacpp/models handler. /insights/models is
  the single endpoint; returns Ollama servers under LLM_BACKEND=ollama
  and llama-swap slots (from LLAMA_SWAP_ALLOWED_MODELS) under
  LLM_BACKEND=llamacpp. Same envelope shape either way.
- New ai::embed_one helper routes embeddings through llama-swap when
  LLM_BACKEND=llamacpp (else Ollama). Wires it into the four
  insight_generator embedding sites.
- Cross-replay matrix simplifies to pre-llamacpp shape (local↔local,
  hybrid↔hybrid, hybrid→local allowed; local→hybrid rejected).
2026-05-21 11:36:58 -04:00
Cameron Cordes d14df63f19 env.example: document LLAMA_SWAP_* + HYBRID_VISION_BACKEND vars
Mirrors the section added to CLAUDE.md so deploys can opt into the
llamacpp backend from the template alone.
2026-05-20 17:54:08 -04:00
Cameron Cordes f0927f5355 ai: add llamacpp backend (llama-swap) as third LLM client
Wires a new LlamaCppClient (OpenAI-compatible /v1 wire format) alongside
OllamaClient and OpenRouterClient. Per-slot routing for chat/vision/embed
via env (LLAMA_SWAP_URL + *_MODEL vars); capability inference uses an
env allowlist since /v1/models doesn't report modality.

InsightGenerator + InsightChatService gain three-way dispatch on
chat_backend = "local" | "hybrid" | "llamacpp". Hybrid and llamacpp
share the describe-then-inline path (text-only chat after a separate
vision describe). HYBRID_VISION_BACKEND=llamacpp lets hybrid route its
describe pass through llama-swap's vision slot while chat still goes
to OpenRouter.

Cross-replay matrix added (validate_cross_replay): local<->llamacpp
and hybrid<->llamacpp allowed; local->hybrid and llamacpp->hybrid
rejected. New /insights/llamacpp/models handler mirrors the OpenRouter
shape.
2026-05-20 17:52:33 -04:00
cameron d04b86e32c Merge pull request 'image: add on-demand size=large preview tier (~2048px JPEG q85)' (#100) from feature/image-large-preview into master
Reviewed-on: #100
2026-05-19 21:51:08 +00:00
Cameron Cordes 19798184f0 image: add on-demand size=large preview tier (~2048px JPEG q85)
Adds a third PhotoSize between Thumb (200px) and Full (original). The
viewer placeholder and map callout previously upscaled a 200px thumb
into a full-screen / full-width view, which looked visibly blocky on
3× devices. The new tier is generated on-demand, disk-cached, and
served via the existing /image endpoint.

Storage layout mirrors the Thumb branch's lookup chain:
  1. hash-keyed: <thumbs>/_large/<hash[..2]>/<hash>.jpg (shared across
     libraries when content_hash is known)
  2. library-scoped legacy: <thumbs>/_large/<lib_id>/<rel_path>

Generation pipeline mirrors generate_image_thumbnail:
  - RAW: decode the embedded JPEG preview, apply EXIF orientation,
         resize to 2048-long-edge, encode JPEG q85
  - HEIC/HEIF: ffmpeg with scale + q:v 5 (≈ q85)
  - everything else: image crate decode + thumbnail() + JpegEncoder
Never upscales — sources below the 2048 cap re-encode at native size.

Handler offloads decode/resize to web::block to keep the actix worker
free (a 24MP source takes 100–500ms). Writes via tempfile+rename so
concurrent readers can't observe a half-written JPEG. On any
generation failure, falls through to the Full branch (which itself
serves the RAW embedded preview for unrenderable RAW containers).

Video requests for size=large fall back to the existing thumb pipeline
since there's no useful 2048px video tier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:14:49 -04:00
cameron c3c6cd03db Merge pull request 'file_types: filter macOS AppleDouble + .DS_Store from media predicates' (#99) from feature/filter-fs-metadata into master
Reviewed-on: #99
2026-05-18 17:12:42 +00:00
Cameron Cordes b843a4a366 file_types: filter macOS AppleDouble + .DS_Store from media predicates
Symptom: Apollo's logs showed bursts of 422 decode_failed from
ImageApi's CLIP backfill — e.g. `._DSC_2182-S.jpg`. macOS writes
`._<name>` AppleDouble sidecars when copying to non-HFS volumes
(SMB, FAT, exFAT), and they carry the original file's extension
even though their bytes are extended-attribute metadata, not the
image. ImageApi's walker matched them via the extension predicate,
sent them through the ingest pipeline, and accumulated failed rows
in face_detections + clip_embedding while pinning Apollo's eviction
timer with the 422 burst.

Fix: predicate-level guard in is_image_file / is_video_file (and
by inheritance is_media_file). Every walker that already gates on
these (face_watch, backfill, clip_watch, watcher, files,
probe_clip_search) inherits the skip without per-callsite edits.
Narrow scope on purpose — `._*` prefix + the exact `.DS_Store`
basename — rather than blanket dotfile filtering, because a user
could plausibly name a cover image `.cover.jpg`.

Existing rows are not cleaned by this change. To purge what
already accumulated (one-shot, run from your DB shell after
deploying):

  DELETE FROM image_exif
   WHERE file_path LIKE '%/._%' OR file_path LIKE '%/.DS_Store';
  DELETE FROM face_detections
   WHERE rel_path LIKE '%/._%' OR rel_path LIKE '%/.DS_Store';
  DELETE FROM tagged_photo
   WHERE file_path LIKE '%/._%' OR file_path LIKE '%/.DS_Store';
  DELETE FROM favorites
   WHERE path LIKE '%/._%' OR path LIKE '%/.DS_Store';

The maintenance pipeline's missing-file scan would NOT catch these
on its own — the files exist on disk (they're real macOS metadata,
just not images), so stat() returns Ok and the row sticks.
2026-05-17 20:10:16 -04:00
cameron d275150db6 Merge pull request 'feature/video-frame-rate' (#98) from feature/video-frame-rate into master
Reviewed-on: #98
2026-05-18 00:09:35 +00:00
Cameron acdffc1558 cargo fmt: drop trailing blank line in actors.rs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:14:30 -04:00
Cameron bd61e10158 chore: add .gitattributes + unit tests for ffprobe rational parser
LF normalization across OSes; *.sql pinned to LF for stable diffs.

Tests cover the rational frame-rate parser (NTSC 29.97, integer fps,
slow-mo 240, ffprobe's 0/0 unknown sentinel, malformed and out-of-range
inputs). Extracted the closure into a free fn for the test seam.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:13:06 -04:00
Cameron 1b70a6f0b4 video: probe frame rate via ffprobe and return on /video/generate
Adds frame_rate to GenerateVideoResponse so the mobile scrubber can step
at the source's real fps instead of a hardcoded 30. probe_video_stream_meta
gains a frame_rate field (avg_frame_rate preferred, r_frame_rate fallback,
nonsense values rejected) and is now pub so the handler can reuse it.
Cost is one ffprobe per /video/generate call; degrades silently to None
on probe failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:03:21 -04:00
cameron 3162a4f477 Merge pull request 'clip-search: accept library_ids (multi-select whitelist) on /photos/search' (#97) from feature/clip-search-library-ids into master
Reviewed-on: #97
2026-05-16 13:38:00 +00:00
Cameron Cordes 87093a63d7 clip-search: accept library_ids (multi-select whitelist) on /photos/search
Previously the endpoint only accepted `library=<id>` (single id) — multi-
select scopes had to be filtered upstream by Apollo, which kept the
filter logic out of FileViewer-React's reach (it calls ImageApi
directly and got no scoping for 2+ active libraries).

Adds `library_ids` (comma-separated id list, e.g. `?library_ids=1,3`).
Parsed inside the existing scope decision: `library_ids` wins when
both are supplied; either / both empty falls back to "every enabled
library" (historical default). Malformed entries return 400.

Dedupes ids while preserving order so a stray `library_ids=1,1,3`
doesn't double-pass to the DAO. The single-id path still works
unchanged for older clients.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 09:30:46 -04:00
cameron dd7b4befb6 Merge pull request 'feature/clip-semantic-search' (#96) from feature/clip-semantic-search into master
Reviewed-on: #96
2026-05-16 00:32:32 +00:00
Cameron Cordes 922f7df8d3 clip-search: offset-based pagination on /photos/search
Adds `offset` query param (default 0) and `total_matching` + `offset`
response fields. Backend already computes the full sorted list of
above-threshold matches per query; pagination just slices it at
[offset, offset+limit) instead of always returning the top window.
Offsets past the end return an empty page cleanly so the client can
stop fetching naturally.

Re-scores on every page rather than caching the sorted list — at
personal-library scale (~14k embeddings, 768d) the dot-product loop
is sub-100ms and the lack of state means no eviction / staleness
concerns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:56:10 -04:00
Cameron Cordes ee2ed3005b clip-search: document env knobs in .env.example
APOLLO_CLIP_API_BASE_URL (falls back to APOLLO_API_BASE_URL),
CLIP_BACKLOG_MAX_PER_TICK, CLIP_ENCODE_CONCURRENCY, and
CLIP_REQUEST_TIMEOUT_SEC — all of which the code already reads.
Apollo's side was documented earlier; this closes the parity gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:10:52 -04:00
Cameron Cordes 66267cc345 clip-search: fmt + clippy clamp + test AppState arg
Pulls cargo fmt + clippy pass over the new files only — pre-existing
files left untouched even though fmt has drift on them. clamp(1,200)
swaps a manual min/max chain that clippy flagged. test AppState
constructor needed ClipClient::new(None) so the lib-test target
compiles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:10:52 -04:00
Cameron Cordes 32195ed89e clip-search: backlog drain + /photos/search endpoint
Wires the persistence layer for CLIP semantic search. The watcher's
per-tick drain encodes any image_exif row with a known content_hash
but no clip_embedding via Apollo (cap CLIP_BACKLOG_MAX_PER_TICK,
default 32). On a query, /photos/search encodes the text via Apollo
and reranks every stored embedding in-memory.

ExifDao additions:
- list_clip_unencoded_candidates — partial-index scan for drain
- backfill_clip_embedding — touches only the two new columns
- list_clip_index — dedup'd (hash, embedding) pull for search

clip_watch::run_clip_encoding_pass is the parallel fan-out — tokio
runtime per pass with CLIP_ENCODE_CONCURRENCY (default 4). No marker
rows for permanent failures yet; per-tick cap bounds the retry cost.

/photos/search params: q, limit, threshold (default 0.20), library,
model_version. Response is intentionally minimal (path + score) so
the frontend joins against existing photo-metadata routes lazily.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:10:52 -04:00
Cameron Cordes 8d9e76cf15 clip-search: migration + client + probe binary
Probe-phase scaffolding for CLIP semantic search. Adds the column
that will hold per-photo embeddings, the HTTP client to Apollo's
inference service, and a throwaway probe binary so we can eyeball
search-result quality on the live library before building the
persistence layer (backlog drain, /photos/search endpoint, UI).

- migrations/2026-05-14-000000_add_clip_embedding/ — adds
  image_exif.clip_embedding (BLOB) and clip_model_version (TEXT),
  plus a partial index on (clip_embedding IS NULL AND content_hash
  IS NOT NULL) for the future backfill drain.
- src/database/models.rs — extends ImageExif struct to match.
- src/ai/clip_client.rs — encode_image / encode_text / health,
  same Permanent/Transient/Disabled taxonomy as face_client.
- src/bin/probe_clip_search.rs — --query <q> --library N --limit M
  --top K. Encodes a sample and prints top-K cosine similarities.
  No DB writes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:10:52 -04:00
cameron 26ffc15c8b Merge pull request 'feature/hls-content-hash' (#95) from feature/hls-content-hash into master
Reviewed-on: #95
2026-05-15 20:09:48 +00:00
Cameron Cordes 0168a4b574 hls: remove legacy /video/stream + /video/{path} routes
The hash-keyed `/video/hls/{hash}/{file}` route fully covers HLS
playback now and both clients (Apollo, FileViewer-React) have
shipped updates that use it directly. Keeping the basename-keyed
fallback only encouraged stale URLs to keep flowing — every legacy
file was deleted by the startup migration, so the routes were
guaranteed 404 machines.

Dropped:
- `stream_video` handler (`GET /video/stream?path=…`) — the original
  basename-keyed playlist serve.
- `get_video_part` handler (`GET /video/{path}`) — bare-filename
  segment serve. The new layout's segments live in
  `<shard>/<hash>/segment_NNN.ts` and reach the client via
  `stream_hls_file`.
- `legacy_path` field on `GenerateVideoResponse` (serialised as
  `playlist`). The field always pointed at a file the migration had
  deleted; current clients ignore it entirely.
- Their service registrations in `main.rs`.
- The body-side `filename` extraction in `generate_video` (existed
  only to construct `legacy_path`) and the now-unused `global`
  opentelemetry import in `handlers/video.rs`.

All 707 tests still pass. Same hand-rolled validators (`is_valid_hash`
/ `is_allowed_hls_filename`) keep the new route's defense-in-depth
intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:00:19 -04:00
Cameron c30cadde02 ai: fix UTF-8 byte-slice panics in insight_generator log/truncation paths
Switch four `&s[..N]` / `&s[..s.len().min(N)]` sites to
`chars().take(N).collect::<String>()` so truncation lands on character
boundaries instead of mid-codepoint. The agentic summary preview log
was panicking when generated content hit an em-dash at byte 200; the
few-shot passage cap, brief_json_args debug formatter, and a test
assertion message had the same latent bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:10:02 -04:00
Cameron Cordes 8503ef7884 chore: cargo fmt + clippy --fix sweep across the crate
Pure mechanical cleanup of accumulated drift in files outside the
HLS-content-hash branch's main change set. No behavior change.

- `cargo fmt` on every previously-misformatted file
  (`ai/insight_generator.rs`, `database/knowledge_dao.rs`,
  `faces.rs`, `knowledge.rs`, `libraries.rs`).
- `cargo clippy --fix`:
  - `needless_borrow`: `&library` → `library` in `handlers/image.rs`
    (two sites in the photo-listing path).
- Manual clippy pass for warnings clippy emits but can't auto-apply:
  - `field_reassign_with_default` in `database/reconcile.rs::run` —
    consolidated into a struct-literal initializer.
  - `needless_range_loop` in `database/knowledge_dao.rs::union_perceptual_tags`
    — inner `for b in (a+1)..indices.len() { let ib = indices[b]; ... }`
    becomes `for &ib in &indices[a + 1..] { ... }`.
  - Doc-list indentation: continuation lines under nested bullets in
    `database/mod.rs::get_memories_in_window` and
    `database/knowledge_dao.rs::build_entity_graph` realigned to the
    list-item content column.

Deliberately not touched (each deserves its own focused commit, with
testing, rather than getting bundled into a sweep):
- 4× `deprecated count_distinct` in `faces.rs` — diesel API migration
  to `AggregateExpressionMethods::aggregate_distinct` may shift result
  types; needs verification against the existing stats queries.
- `await_holding_lock` in `knowledge.rs:807` — `std::sync::Mutex` held
  across `ollama.generate(...).await`. Genuine concurrency bug; fix
  requires understanding the surrounding flow before just dropping
  the guard.
- 2× `type_complexity` in `database/mod.rs` — cosmetic, would need a
  `type` alias and corresponding callers updated.
- Dead `total_deleted` on `library_maintenance::GcStats` and
  `file_scan::enumerate_indexable_files` — both are public surface
  retained for future use; deletion is a separate decision.

All 707 tests still pass. Release build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:25:05 -04:00
Cameron Cordes 8c91bf554b hls: cargo fmt + clippy::cloned_ref_to_slice_refs
Pure mechanical pass on the files this branch added/modified:
rustfmt reflow of a few long lines / chains, and the one
non-pre-existing clippy warning — replacing
`&[rel_path.clone()]` with `std::slice::from_ref(&rel_path)` in
`handlers::video::generate_video` to avoid the alloc + clone for a
single-element slice.

All 707 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:01:16 -04:00
Cameron Cordes 7cd1ea3cf8 hls: per-library readiness gauges + GET /hls/stats endpoint
The hash-keyed pipeline transcodes lazily, so a freshly mounted (or
freshly upgraded) library is "mostly pending" for the first hour
while the watcher works through the backlog. The operator wants a
live read on remaining work so they can tune `HLS_CONCURRENCY` and
know when to stop waiting.

Adds:

- `src/hls_stats.rs` — pure compute path (`stats_from_rows`) and an
  Arc<Mutex<dyn ExifDao>> wrapper (`compute_and_publish`). Per
  library: `total`, `with_playlist`, `pending`, `unsupported`,
  `hashless_videos`. Dedup is by content_hash so duplicate-bytes-at-
  N-paths counts once (same domain rule as `faces::stats`).
  `hashless_videos` is a separate counter so the operator can see
  the "hash backfill, then transcode" pipeline depth instead of
  having NULL-hash rows just hide.

- Prometheus gauges labeled by library name:
  `imageserver_hls_videos_total`, `..._with_playlist`, `..._pending`,
  `..._unsupported`. Updated by the watcher at the end of every full-
  scan tick *and* on every `/hls/stats` hit, so whichever surface the
  operator is watching stays fresh. Registered in `main` alongside
  the existing image/video gauges.

- `GET /hls/stats` — Claims-protected JSON snapshot of the same data
  plus a top-level cross-library aggregate. Runs on a blocking pool
  so it doesn't pin the actix worker; per-call cost is one
  `list_paths_and_hashes_for_library` SQL query per library plus a
  `stat()` per distinct video hash. Bounded — never invoked from
  middleware, only from the explicit endpoint and the full-scan
  tick. The watcher's end-of-tick `info!` summary line mirrors the
  endpoint output for operators tailing the log.

- New `ExifDao::list_paths_and_hashes_for_library` method:
  `SELECT rel_path, content_hash FROM image_exif WHERE library_id =
  ?`. Single round-trip; callers filter to video extensions
  client-side because the schema doesn't carry media-type. Mock
  impl in `files.rs` returns an empty vec.

Tests in `hls_stats::tests` exercise stats_from_rows directly (videos-
only filter, hash dedup, playlist vs sentinel decision, NULL-hash
hashless counting) plus a publish_gauges round-trip that reads the
gauge value back. Full suite (347 lib + 360 bin = 707) passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:58:46 -04:00
Cameron Cordes 7c153596fe hls: hash-keyed HTTP routes for /video/generate and serving
`POST /video/generate` is reshaped to return a JSON object instead of
a bare string. New fields:

- `playlist_url`: stable hash-keyed URL of the form
  `/video/hls/<hash>/playlist.m3u8`. Use this with hls.js / native
  players — relative segment refs inside the playlist resolve to
  `/video/hls/<hash>/segment_NNN.ts` because the URL is path-based.
- `content_hash`: the blake3 hex digest that identifies the bytes.
  Stable across libraries, archive ingests, renames; clients can
  cache the URL by hash.
- `ready`: true iff the playlist file is already on disk. False means
  a transcode was just queued; the client should retry the URL after
  a short delay (or rely on hls.js's built-in retry).
- `playlist` (legacy): basename-keyed path string, echoed under the
  old field name so clients that destructure `response.playlist` keep
  working during the rollout. The startup migration deletes the
  underlying file, so this URL will 404; clients should migrate to
  `playlist_url`. Field is slated for removal once Apollo / File
  Viewer ship the update.

The handler:
- resolves the source path across libraries (same logic as before),
- looks up `image_exif.content_hash` for that (library_id, rel_path),
- falls back to inline `content_hash::compute` when the row is mid-
  backfill — pure read, no library mutation,
- sends a single-element `QueueVideosMessage` to `VideoPlaylistManager`
  if the playlist isn't already on disk and there's no
  `playlist.unsupported` sentinel,
- returns the URL immediately. The actor pipeline owns transcoding.

New route `GET /video/hls/{hash}/{file}`:
- strict validation: hash must be 64 ascii-hex chars; file must be
  `playlist.m3u8` or `segment_NNN.ts` (digits only). Anything else
  returns 400 so we never have to rely on path canonicalisation
  alone to defend against traversal,
- belt-and-suspenders canonicalize() guard verifies the resolved
  file lives under `$VIDEO_PATH`,
- serves with the standard `NamedFile::into_response` machinery.

Cleanup in `actors.rs`:
- `ProcessMessage` + its `StreamActor` handler had no senders after
  the rewire — removed. `StreamActor` itself stays (still handles
  `RefreshThumbnailsMessage` from `files.rs`).
- `create_playlist`, `playlist_file_for`,
  `playlist_unsupported_sentinel` are gone — the legacy on-demand
  transcode helper and the migration-only path helpers had no
  remaining users (the migration uses its own classify() function).
- Imports tightened: dropped `Child`, `ExitStatus`, `trace`.

Tests cover both new validators (`is_valid_hash`,
`is_allowed_hls_filename`) including the strings that motivated the
defence-in-depth (traversal attempts, internal `.tmp`/`.unsupported`
artifacts, malformed segment names).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:51:01 -04:00
Cameron Cordes 78fabc2b32 hls: retire legacy basename-keyed HLS files on startup
Adds `video::legacy_migration::retire_legacy_hls_output`, called once
from `main` right after the diesel migrations run and before the
actor pipeline starts. Walks `$VIDEO_PATH` at depth 1, deletes every
`.m3u8` / `.m3u8.tmp` / `.m3u8.unsupported` / `.ts` file at root, and
logs a single info line with per-class counts. Skips directories
(the new layout's `<shard>/<hash>/` lives there) and unknown
extensions, so an operator's stashed README or `.tmp` from a
different tool is safe.

Why this needs its own one-shot pass rather than letting the rewritten
`cleanup_orphaned_playlists` handle it: the cleanup walk deliberately
only looks at `<shard>/<hash>/` dirs (so it can't accidentally `rm`
operator-stashed content), so without this migration the legacy files
would sit at root forever, never served, never refreshed. Operator
complaint count from the previous IMG_NNNN.MOV collision: ~10
duplicate-basename hits on one library alone; total .m3u8 count was
699 vs a much larger video count — i.e. the loser of every collision
was a permanent orphan. This pass collects all of them, then the
running watcher writes hash-keyed playlists going forward.

Idempotent — a second boot finds nothing and reports zero deletions,
so the call site can stay in `main` across releases until the module
is removed in a later cleanup commit. Tests cover the happy path
(legacy artifacts gone, hash dir untouched, unrelated files left
alone), idempotency, and the missing-directory case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:43:13 -04:00
Cameron Cordes b8e17e05b7 hls: rewrite orphan cleanup for hash-keyed layout
The cleanup walk previously looked for `$VIDEO_PATH/<basename>.m3u8`
and matched each file's stem against a recursive walk of every
library. With the hash-keyed layout now in place, every playlist's
file_stem is the literal string "playlist" — the old logic would
treat every hash-keyed playlist as orphaned on its next run and wipe
them all in one tick (default cleanup interval is 24h, so this is a
24-hour bomb on top of the prior commit).

New approach: orphan-ness is decided in the database, not on the
filesystem. The cleanup loop:

- Snapshots every distinct non-NULL `image_exif.content_hash` into a
  HashSet (new `ExifDao::list_distinct_content_hashes` method —
  `SELECT DISTINCT content_hash WHERE content_hash IS NOT NULL`).
- Walks `$VIDEO_PATH` two levels deep: top-level entries are filtered
  to 2-char lowercase hex shard dirs, each shard's children to 64-char
  hex hash dirs. Anything else (legacy `.m3u8` at root from the
  pre-content-hash era, operator-stashed dirs, partial writes) is left
  alone.
- Hash dirs whose hash isn't in the alive set are `remove_dir_all`'d.
  Shard dirs that emptied as a result are reaped on the same pass via
  `remove_dir` (no-op if non-empty).
- The library-stale safety gate is preserved: a stale library skips
  the cycle even though the orphan decision is DB-only, because the
  upstream missing-file scan that retires `image_exif` rows itself
  pauses for stale libraries. Belt-and-suspenders — keeping a hash
  dir for one extra 24h cycle is cheaper than wiping one whose source
  was briefly unreachable. The gate now also filters disabled
  libraries out of the stale set (they're intentionally absent from
  the health map).
- The legacy `excluded_dirs` parameter is preserved on the function
  signature but unused (the walk no longer crosses library trees);
  flagged with a leading underscore. Callers in `main.rs` stay
  unchanged.

`MockExifDao` in `files.rs` grows the new method (returns empty);
unit tests for the new `is_hash_shard` / `is_full_hash` validators
guard against an operator's stashed directory under VIDEO_PATH ever
matching the orphan-rm path. Both pass.

A follow-up commit handles the one-shot startup migration that
retires the legacy basename-keyed `.m3u8` / `.ts` files at
`$VIDEO_PATH` root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:41:04 -04:00
Cameron Cordes d1667099c3 hls: rewire queue + generator to write hash-keyed playlists
Switches the watcher → VideoPlaylistManager → PlaylistGenerator path
from the basename-keyed layout
(`$VIDEO_PATH/{basename}.m3u8`) to the hash-keyed layout
(`$VIDEO_PATH/{hash[..2]}/{hash}/playlist.m3u8`) introduced in the
prior commit. Source videos that share a basename across libraries
(or across subdirs of one library) no longer overwrite each other's
playlists. The legacy HTTP endpoints in `/video/generate` /
`/video/stream` still use the basename layout — those move in a
follow-up commit alongside the stable streaming URL.

actors.rs:
- `QueueVideosMessage.video_paths: Vec<PathBuf>` →
  `videos: Vec<VideoToQueue>`. The queue handler dedups against the
  hash-keyed playlist + sentinel and forwards `GeneratePlaylistMessage`
  carrying the hash.
- `GeneratePlaylistMessage` now carries `content_hash: String`; the
  legacy `playlist_path: String` field is gone.
- `PlaylistGenerator` takes a `video_dir: PathBuf` at construction,
  computes the hash dir + playlist + sentinel + segment template via
  `hls_paths`, `mkdir -p`s the shard/hash dir before ffmpeg runs, and
  cleans up partial output on failure by walking the hash dir.
- `ScanDirectoryMessage` and its handler are retired entirely; their
  startup-walk role is taken over by the watcher's first tick (see
  `watcher.rs` below). Dropping it avoids threading an `ExifDao` into
  `VideoPlaylistManager` just so the actor can resolve hashes.
- Legacy `playlist_file_for` / `playlist_unsupported_sentinel` are
  retained behind `#[allow(dead_code)]` for the upcoming migration
  pass that retires pre-content-hash output.

watcher.rs:
- `process_new_files` keeps `content_hash` in the EXIF-batch result
  (formerly threw it away). Videos with `image_exif.content_hash =
  NULL` — mid-backfill rows — are skipped this tick rather than
  falling back to a basename-colliding playlist; they get picked up
  after `backfill_unhashed_backlog` populates the hash on a
  subsequent tick. Skipped count is logged at debug.
- The video staleness check now uses `hls_paths::playlist_for_hash`
  instead of `$VIDEO_PATH/{basename}.m3u8`.
- `last_full_scan` initialises to `UNIX_EPOCH` so the watcher's first
  tick is treated as a full scan. That covers the catch-up gap left
  by removing `ScanDirectoryMessage` — every library's existing media
  is checked once at watcher boot (≈60s after startup) instead of
  waiting up to `WATCH_FULL_INTERVAL_SECONDS` (1h default).

main.rs: removes the `ScanDirectoryMessage` import and the per-library
`do_send` loop, with a comment pointing at the watcher's first-tick
behavior.

state.rs: `PlaylistGenerator::new` now takes the video dir.

Tests: existing `video::hls_paths` (4) and `watcher::tests` (4) pass.
The basename-keyed `/video/generate` endpoint still compiles and
serves; behavior change there is deferred to the follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:36:01 -04:00
Cameron Cordes c71e1cdce0 hls: add hash-keyed path helpers + VideoToQueue type
Foundation for migrating HLS playlist output from basename-keyed
(`$VIDEO_PATH/{basename}.m3u8`) to content-hash-keyed
(`$VIDEO_PATH/{hash[..2]}/{hash}/playlist.m3u8`). The basename layout
collides whenever two source videos share a filename — common with
iPhone-style sequential naming (`IMG_NNNN.MOV`) across libraries — so
the loser's playlist gets overwritten and ffmpeg keeps re-queueing the
file every scan.

This commit adds the path layout and type plumbing without touching the
actor pipeline, watcher, or HTTP handlers yet:

- `src/video/hls_paths.rs`: `playlist_for_hash`, `sentinel_for_hash`,
  `segment_template_for_hash` built on top of `content_hash::hls_dir`,
  with constants for the filenames inside the hash dir. Unit tests
  cover the sharded layout and the playlist/sentinel/segment paths
  all landing in the same directory (so HLS relative refs resolve).
- `src/content_hash::hls_dir` un-deaded — was waiting for this branch.
- `VideoToQueue` struct in `actors.rs`: pairs a source path with its
  content hash so callers that lack a hash (rows mid-backfill) skip
  the video rather than fabricate one.
- `playlist_file_for` / `playlist_unsupported_sentinel` retained as
  migration-only helpers — they're only needed by the one-shot startup
  pass that retires pre-content-hash output.

Follow-ups (separate commits on this branch): wire `hls_paths` through
the queue handler + `PlaylistGenerator`, update the watcher's
`process_new_files` to build `VideoToQueue`, switch `/video/generate`
and `/video/stream` to resolve path→hash and return stable URLs, add
the legacy-layout migration, rewrite `cleanup_orphaned_playlists` for
the new dir shape, and surface progress via Prometheus + `/hls/stats`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:23:31 -04:00
cameron 22ce1a20e7 Merge pull request 'feature/library-patch-endpoint' (#94) from feature/library-patch-endpoint into master
Reviewed-on: #94
2026-05-13 13:44:36 +00:00
Cameron Cordes 7ec156fc05 libraries: accept newline as an excluded_dirs separator
Splits parse_excluded_dirs_column on `,`, `\n`, AND `\r` so a textarea
submit with one entry per line works the same as comma-separated.
Mixed input (`a, b\nc`) parses cleanly too — the frontend can paste
from any source without preprocessing.

Motivated by the "forgot the comma" footgun: typing
`.thumbnails .thumbnails2` in a single-line input today stores a
never-matching component pattern. With newlines as a first-class
separator and the frontend switching to a textarea, the natural
one-per-line UX makes that mistake impossible.

The DB store form stays comma-joined (normalize_excluded_dirs_input
hasn't changed) so existing rows are unaffected and no migration is
needed. Newline support matters mostly for the inbound write path;
mirroring it on the read side keeps the parser round-trip safe in
case anything writes a newline form directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:23:51 -04:00
Cameron Cordes 439532377d libraries: validate excluded_dirs entries on write
Reject the silent-footgun shapes that PathExcluder would store but
never match. The watcher would still walk past every photo as if the
exclude wasn't there, and the operator would have no signal that
their entry is dead. Caught at PATCH time with a descriptive 422.

Rules:
- Backslash anywhere → "use forward slashes" (catches \photos,
  photos\2024, \\server\share — Windows-typed entries land in the
  component-pattern bucket and never fire).
- Drive-letter prefix (Z:, Z:/...) → "relative to library root" —
  excludes are root-relative, not absolute system paths.
- Multi-segment name with no leading slash (photos/2024) →
  "did you mean /photos/2024?" — the common "I forgot the slash"
  typo, today silently stored as a component pattern that never hits.
- `..` segments in a path entry → "doesn't normalise". base.join()
  doesn't canonicalise, so the resulting prefix never matches.
- Bare "/" → "almost certainly a typo" for the library root.

Trailing slashes on path entries are stripped silently. Eight new
tests cover each rejection plus the trailing-slash normalisation
and the all-or-nothing failure mode of normalize_excluded_dirs_input
(one bad entry aborts the whole patch rather than silently applying
N-1 of N changes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:02:29 -04:00
Cameron Cordes ce9fa94cb4 libraries: surface globals, normalise excluded_dirs on write
Two follow-ups to the PATCH endpoint:

1. GET /libraries now returns ``global_excluded_dirs`` alongside the
   library list — the union-with-globals semantics is invisible from
   the per-library row alone, and the admin UI needs to show what's
   already being skipped before the operator adds entries that would
   duplicate.

2. PATCH /libraries/{id} canonicalises the excluded_dirs string on
   write via the new ``normalize_excluded_dirs_input``: trims per
   entry, drops empties, dedupes preserving first-occurrence order,
   comma-joins without inner whitespace. Empty / whitespace-only →
   NULL. Round-trip stable so re-saving an entry produces an
   identical row.

Five new tests cover the empty / whitespace, trim, dedup, round-trip,
and overlap-with-globals cases. effective_excluded_dirs continues to
keep overlapping entries between globals and per-library on purpose —
PathExcluder accepts repeats and there's no behavioural reason to
dedupe at merge time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:58:04 -04:00
Cameron Cordes b3124437ec libraries: PATCH /libraries/{id} with live-apply
Adds an HTTP mutation surface for `libraries.enabled` and
`libraries.excluded_dirs`, replacing the SQL-only workflow noted in
CLAUDE.md. Apollo's Settings panel calls this from the LIBRARIES
section so the operator no longer has to ssh + sqlite3 to flip a
library off or edit its excludes.

Live-apply (no restart) via a new `live_libraries: Arc<RwLock<Vec<
Library>>>` field on AppState. The existing immutable `libraries`
Vec stays for hot-path handlers that only need stable id → root_path
lookups, avoiding a 19-call-site refactor. The watcher and
cleanup_orphaned_playlists now take the lock instead of a Vec
snapshot and re-read at the top of each tick, so `enabled` /
`excluded_dirs` changes are picked up within one
WATCH_QUICK_INTERVAL_SECONDS. The GET /libraries handler also reads
through the live view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:47:35 -04:00
cameron 74bf693878 Merge pull request 'feature/date-backfill-null-only' (#93) from feature/date-backfill-null-only into master
Reviewed-on: #93
2026-05-12 18:42:21 +00:00
Cameron Cordes 2d56047497 Drop fs_time from date-backfill eligibility
The drain queried `date_taken IS NULL OR date_taken_source = 'fs_time'`
ORDER BY id ASC LIMIT 500 every watcher tick. The resolver is
deterministic on file bytes + filename + fs metadata, so any row that
landed on fs_time once landed there again on every retry — the drain
spun on the same lowest-id rows in perpetuity, never advancing to
rows 501+ while still logging more_remain=true.

Side effect: 500 auto-commit UPDATEs per tick sustained the SQLite
write lock long enough that other writers on separate DAO connections
hit the 5s busy_timeout. Manifested as intermittent 500s on
PATCH /image/faces/{id} that succeeded on retry.

Narrow the partial index and query predicate to `date_taken IS NULL`.
If exiftool installs or a new filename regex lands, an operator can
re-resolve fs_time rows out-of-band rather than re-introducing the
steady-state churn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:37:36 -04:00
Cameron Cordes 3427c2916c Log 500-return paths in PATCH /image/faces/{id}
The four 500-return paths in update_face_handler returned e.to_string()
in the body but never logged. When a face PATCH failed with a 16-byte
body and no log entry, the cause (SQLITE_BUSY from cross-DAO writer
contention exhausting the 5s busy_timeout) was invisible. Surface the
full anyhow chain via {:#} on each path so the diesel cause is in the
log even when the response body only shows the top-level context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:37:26 -04:00
cameron 6a3e37b7dc Merge pull request 'feature/split-main-rs' (#92) from feature/split-main-rs into master
Reviewed-on: #92
2026-05-12 17:02:06 +00:00
Cameron Cordes 9f8a69fc6d Split main.rs: extract watcher loop into src/watcher.rs
main.rs drops from 1200 → 346 lines (90% smaller than the pre-branch
3542). What's left is the startup wiring it was always meant to be:
.env, migrations, AppState construction, route registration, server
bind. The four background-loop functions move into src/watcher.rs:

- watch_files (310 lines) — quick/full scan tick, per-library probe,
  backfill drain dispatch, missing-file scan, back-ref refresh,
  orphan GC.
- process_new_files (351 lines) — file walk → EXIF write →
  face-candidate build → HLS / preview-clip queueing →
  reconciliation. The "biggest untested chunk" from the earlier
  audit.
- cleanup_orphaned_playlists (167 lines) — separate slower-tick
  thread.
- playlist_needs_generation — small mtime-comparison helper.

Plus 4 unit tests for playlist_needs_generation (covers missing
playlist, newer playlist, newer video, video-missing-metadata
fallback).

main.rs's imports correspondingly shrink — Addr, HashSet, WalkDir,
Utc, InsertImageExif, and the bulk of video::actors all leave with
the watcher. CLAUDE.md updated to reflect the new module layout
(layered architecture box + module map for the face-detection
section).

cargo test --bin image-api: 329 passing (no regression).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:54:37 -04:00
Cameron Cordes bdb69c7d37 Split main.rs: extract HTTP handlers into src/handlers/
main.rs drops from 2935 → 1200 lines, freed for startup wiring +
the watcher. The 16 route handlers move into three domain-grouped
files under src/handlers/:

- handlers/favorites.rs (128 lines): favorites, put_add_favorite,
  delete_favorite.

- handlers/video.rs (665 lines): generate_video, stream_video,
  get_video_part, get_video_preview, get_preview_status. The 5
  pre-existing get_preview_status integration tests move with the
  handler (still pass against TestPreviewDao + AppState::test_state).

- handlers/image.rs (1003 lines): get_image (with the
  hash/library-scoped/bare-legacy thumb lookup), upload_image,
  get_file_metadata, set_image_gps, get_full_exif, set_image_date,
  clear_image_date. Helpers (create_circular_thumbnail,
  build_metadata_response_for_date_mutation) and request structs
  (SetGpsRequest, SetDateRequest, ClearDateRequest, UploadQuery)
  travel with them.

main.rs's import block shrinks from ~50 lines to ~22 as everything
HTTP-specific (NamedFile, mp::Multipart, BytesMut, Span, KeyValue,
StreamExt, …) moves with the handlers. The is_video_file wrapper
also goes — remaining callers in watch_files / cleanup use
file_types::is_video_file directly.

cargo test --bin image-api: 325 passing (no regression).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:38:17 -04:00
Cameron Cordes bec9857426 Split main.rs: extract backfill drains and thumbnails into modules
main.rs drops from 3542 → ~2930 lines by moving:

- src/backfill.rs (new): backfill_unhashed_backlog,
  backfill_missing_date_taken, backfill_missing_content_hashes,
  build_face_candidates, process_face_backlog. Now unit-tested for
  the first time — 5 tests covering cap behavior, library-id
  filtering, missing-on-disk skip, and the video/unhashed/scanned
  filters on face-candidate selection.

- src/thumbnails.rs (new): unsupported_thumbnail_sentinel,
  generate_image_thumbnail, create_thumbnails, update_media_counts,
  is_image, is_video, plus the IMAGE_GAUGE / VIDEO_GAUGE Prometheus
  metrics. Replaces the no-op stubs that used to live in lib.rs.
  4 new unit tests for the sentinel path math and the
  walker-counts-images-vs-videos smoke path.

Supporting:
- SqliteExifDao::from_shared (test-only) so an SqliteExifDao and
  SqliteFaceDao can share one in-memory connection — required to
  test build_face_candidates against the real join.
- files.rs / video/{mod,actors}.rs import from crate::thumbnails::*
  instead of the now-removed stubs in lib.rs.

cargo test --bin image-api: 325 passing (was 314).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:22:02 -04:00
cameron 05ec5d0c70 Merge pull request 'feature/knowledge-curation' (#91) from feature/knowledge-curation into master
Reviewed-on: #91
2026-05-12 15:40:55 +00:00
Cameron Cordes e67e00ef8a knowledge: predicate-quality nudge + bulk-reject endpoint
Two coupled changes to fight the speech-act-predicate problem
(facts like (Cameron, expressed, "I'm tempted to...")):

1. System prompt grows an explicit predicate-quality rule. The
   agent is told to use relationship-shaped verbs (lives_in,
   works_at, attended, is_friend_of, interested_in), and is
   given an explicit DON'T list (expressed, said, mentioned,
   stated, quoted, noted, discussed, thought, wondered). Plus a
   concrete Bad / Good example contrasting the noise pattern
   with the structured paraphrase the agent should be writing.
   Stops the bleed for new insights.

2. Cleanup tools for the legacy noise that's already in the
   table:
   - get_predicate_stats(persona, limit) returns
     [(predicate, count)] sorted desc — feeds the curation UI's
     PREDICATES tab.
   - bulk_reject_facts_by_predicate(persona, predicate, audit)
     flips every ACTIVE fact under that predicate to 'rejected'
     in one transaction, stamping last_modified_* so the action
     is attributable + reversible per-fact through the entity
     detail panel. REVIEWED facts under the same predicate are
     left alone — the curator may have hand-approved an
     exception ("interested_in" might be largely noise but a
     reviewed entry is intentional).

New HTTP endpoints:
   GET  /knowledge/predicate-stats?limit=
   POST /knowledge/predicates/{predicate}/bulk-reject

Persona-scoped via the existing X-Persona-Id header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:50:26 -04:00
Cameron Cordes fb078b4906 knowledge: normalize legacy entity_type values
One-shot migration that re-applies the synonym map from
`normalize_entity_type` over every existing row, so legacy
entries written before that helper landed in upsert_entity stop
needing client-side workarounds.

  person ← person | people | human | individual | contact
  place  ← place | location | venue | site | area | landmark
  event  ← event | occasion | activity | celebration
  thing  ← thing | object | item | product

Unknown types ("friend", "family", etc.) get a lowercase+trim
sweep so at minimum case variants collapse — the curator can
merge or rename them via the curation UI from there.

`UPDATE OR IGNORE` skips rows that would violate UNIQUE(name,
entity_type) after the rewrite (e.g. an existing ("Sarah",
"person") + ("Sarah", "Person") pair). The duplicate survives
unchanged so it can be merged through the normal curation flow
rather than silently disappearing.

Idempotent: every UPDATE is conditional on `entity_type !=
canonical`, so re-running the migration is a no-op. The down
migration is intentionally inert — we don't have per-row
history of the original strings and the rewritten values stay
semantically correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:42:51 -04:00
Cameron Cordes d123cde333 knowledge: entity-graph endpoint for force-directed view
New GET /knowledge/graph?type=&limit= returns the data the
curation UI's graph tab needs:
  - nodes = entities with at least one in-scope fact (rejected /
    superseded excluded). Carries fact_count for visual sizing.
    Top-N by count desc; default cap 200 (clamped 1..1000).
  - edges = relational facts (object_entity_id set) grouped by
    (subject, object, predicate) so 3 "is_friend_of" facts
    between the same pair collapse into one edge with count=3.

Two raw SQL queries: an INNER JOIN onto a persona-scoped fact-
count subquery for nodes (skips 0-fact entities entirely so the
sim doesn't waste time on disconnected islands), then a follow-
up GROUP BY over the persona-scoped fact set restricted to the
node id set via IN clauses (ids are i32 so inlining is safe).

Pairs with the Apollo-side GraphPanel that runs d3-force over
the returned payload and renders SVG with click-to-open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:26:02 -04:00
Cameron Cordes 6dca0c027d fmt: cargo fmt sweep
No logic changes - line reflow, brace placement, and method-chain splits
across handlers / personas / state / faces / knowledge / insights_dao /
knowledge_dao / populate_knowledge. Picked up incidentally while running
fmt for the sms-search work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:21:00 -04:00
Cameron Cordes 7329cc5ce7 insights: push sms search filters server-side, render snippets, expand fts5 docs
- Refactor search_messages_with_contact -> search_messages(query, &SmsSearchParams)
  exposing date_from / date_to / offset / is_mms / has_media; drop the over-fetch
  + client-side date post-filter that could silently drop in-window hits past
  position 100.
- Surface SMS-API's <mark>-wrapped snippet for MMS messages that only matched
  via message_parts_fts (attachment text / filename) - pre-snippet, those
  rendered as a blank body preview to the LLM.
- Expose is_mms / has_media on the search_messages tool schema; expand the
  FTS5 syntax docs with worked examples for phrase / prefix / boolean / NEAR
  / grouping so the model picks the right operator.
- Unit tests for format_search_hits (body fallback, snippet preferred, MMS
  attachment-only regression, empty-snippet fallback) and strip_mark_tags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:20:19 -04:00
Cameron Cordes 6620fa48d7 knowledge: consolidation proposals endpoint
Finds near-duplicate entities the upsert-time cosine guard didn't
catch — typically legacy data from before that guard landed, or
pairs whose embeddings sit between 0.85 (default proposal floor)
and 0.92 (auto-collapse threshold). Pure read-side feature; the
actual merging still goes through the existing
/knowledge/entities/merge action.

New DAO method `find_consolidation_proposals(threshold,
max_groups)`:
  - Loads every non-rejected entity with an embedding.
  - Partitions by entity_type so a person can't cluster with a
    place.
  - Pairwise cosine, edges above threshold feed a union-find for
    transitive grouping (Sara → Sarah → Sarah J. all land in one
    cluster).
  - Tracks min/max cosine per component so the UI can show "how
    tight" each cluster is before clicking in.
  - Returns groups of >= 2 sorted by size desc then max cosine
    desc; trimmed to `max_groups`.

New endpoint `GET /knowledge/consolidation-proposals?threshold=
&limit=` accepts the threshold (clamped 0.5–0.99 to prevent the
"every entity in one mega-cluster" case) and returns groups with
per-entity persona fact-count breakdowns baked in — saves the UI
a separate query per cluster member.

ConsolidationGroup is exported through database/mod.rs so the
handler can use it without depending on knowledge_dao internals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:43:11 -04:00
Cameron Cordes 89d0a6527c knowledge: per-entity persona breakdown for list + detail
Entities are global; facts are persona-scoped. Under the active
persona an entity can read as "0 facts" while having plenty under
other personas the user owns — the curation UI had no way to
surface that gap. Adds a batched DAO method
`get_persona_breakdowns_for_entities` that returns
{entity_id → [(persona_id, count)]} in one query (group by
subject + persona, user-scoped, status != rejected), and wires it
into both /knowledge/entities list rows and
GET /knowledge/entities/{id}.

EntitySummary grows an optional `persona_breakdown` field
(skipped on serialization when None — keeps PATCH responses
unchanged). EntityDetailResponse carries the breakdown as a
non-optional Vec since the detail endpoint always populates it.

One extra query per list page (50 entities → 50 subject ids
batched in one IN clause); single-entity GET adds one round trip.
Indexed by (subject_entity_id, persona_id) implicitly via the
existing user-persona indexes on entity_facts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:29:20 -04:00
Cameron Cordes f200466508 knowledge: forbid markdown in synthesized merge descriptions
System prompt now explicitly enumerates the markdown forms the
model shouldn't emit (bold, italics, headings, bullets, lists,
code fences) on top of the existing "no preamble, no quotes"
constraints. Some local models default to markdown-shaped
output for descriptions and the curation UI is plain-text,
which would render the asterisks and hashes literally.

The output cleaning step picks up a parallel sweep: strip code
fences, leading bullets / headings, wrapping quotes, and naive
inline emphasis markers (** and __). Rare enough that the
plain-replace is fine; not trying to parse markdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:49:02 -04:00
Cameron Cordes afac02cade knowledge: synthesize-merge endpoint for LLM-curated descriptions
New POST /knowledge/entities/synthesize-merge { source_id,
target_id } that calls the local Ollama with both entities' names
+ descriptions and returns a synthesized merged-description draft.
Read-only on the database — the curation UI uses the response as
the editable seed in the merge picker; the actual merge still
requires a follow-up PATCH-target-description + POST /merge.

The handler drops the KnowledgeDao lock before the LLM call so
other knowledge reads aren't blocked while generation runs
(typically seconds). Failure mode is 503 with an explicit hint
that the UI should fall back to skip-synthesis — keeps the merge
action working when the model is offline.

Output is lightly cleaned (leading "Merged description:" /
surrounding quotes stripped) since small models reach for those
patterns even with explicit "no preamble" guidance. Heavier
parsing isn't worth it — the curator edits anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:37:26 -04:00
Cameron Cordes fd4dd89bbb knowledge: agent self-correction with audit + per-persona gate + revert
Bundles three coupled changes so agent-side mutations stay
auditable and reversible:

1. Audit columns on entity_facts —
   `last_modified_by_model` / `last_modified_by_backend` /
   `last_modified_at`. Stamped on every mutation path
   (update_fact, supersede_fact, manual PATCH, manual supersede,
   the new revert). NULL on rows never touched since creation.
   Partial index on `last_modified_at WHERE NOT NULL` keeps the
   "show me recent edits" feed fast without bloating from legacy
   rows.

2. Per-persona gate `personas.allow_agent_corrections` (BOOLEAN,
   default 0). Defense in depth at two layers:
   - build_tool_definitions: when off, `update_fact` and
     `supersede_fact` aren't in the catalog at all, so even a
     hallucinated tool call by the model fails fast.
   - tool_update_fact / tool_supersede_fact: re-checks the persona
     flag at call time and returns an explicit "corrections
     disabled" error if it's somehow off (e.g. flag flipped mid-
     loop).
   ToolGateOpts grows the flag; current_gate_opts splits into
   `current_gate_opts` (no persona context, defaults closed) +
   `current_gate_opts_for_persona` for chat callers that have a
   persona id. Both call sites in insight_chat are updated.

3. Revert action — new DAO method `revert_supersession` +
   `POST /knowledge/facts/{id}/restore`. Flips status back to
   'active', clears `superseded_by`, clears `valid_until` (we
   don't track whether it was hand-set vs auto-stamped, so the
   safe reset is to drop it — user can re-bound after). Stamps
   `last_modified_*` so the revert itself is attributable.

Manual paths (PATCH / supersede via HTTP, plus restore) stamp the
audit columns with `("manual", "manual")`. Agent paths stamp the
loop-time chat model and backend (mirroring the existing
created_by_* convention).

FactDetail in the HTTP response now carries the audit triple
alongside the existing provenance. Apollo wires the new field set
in the matching commit.

PersonaView / UpdatePersonaRequest grow `allowAgentCorrections`;
the PersonaPatch + InsertPersona + bulk_import paths thread it.

317 lib tests pass, including unchanged update_fact / supersede
DAO tests (now passing audit=None — None means "no provenance
context to attribute", legacy semantics).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:56:56 -04:00
Cameron Cordes 86c331571d knowledge: per-persona reviewed-only mode + agent reads include reviewed
Two coupled changes to the agent's recall surface:

1. Default scope expanded. recall_facts_for_photo and recall_entities
   used to filter to status='active' only — which silently dropped
   'reviewed' (human-verified) facts. Now they surface active +
   reviewed by default. Reviewed is strictly more trusted than
   active and shouldn't have been hidden. Rejected and superseded
   stay filtered.

2. New persona toggle `reviewed_only_facts` (BOOLEAN, default false,
   migration 2026-05-10-000400). When set, the agent's recall on
   that persona returns ONLY facts with status='reviewed' — strict
   mode for tasks where hallucinated agent claims are particularly
   costly. Wired:
   - schema.rs / Persona / InsertPersona / PersonaPatch grow the
     field.
   - PersonaView returns it as `reviewedOnlyFacts` (camelCase wire).
   - PUT /personas/{id} accepts it (mobile editor surfaces it).
   - InsightGenerator now carries a PersonaDao reference so
     recall_facts_for_photo can read the active persona's flag at
     start; one extra read per recall, cheap.

Composes with include_all_memories: that operates on the persona
*scope* axis (single vs hive), reviewed_only_facts on the *status*
axis. They're orthogonal.

Legacy persona rows pick up the default false on migration; no
behavior change unless explicitly toggled. The 4 existing persona
construction sites (one production, two tests, one InsertPersona in
knowledge_dao tests) all default the field. populate_knowledge bin
+ state.rs constructors also wire the new persona_dao arg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:21:39 -04:00
Cameron Cordes f53338923d knowledge: stamp model + backend on facts for audit
Adds two nullable TEXT columns to entity_facts —
`created_by_model` (LLM identifier) and `created_by_backend`
("local" / "hybrid" / "manual" / NULL) — so the curator can audit
which configurations produce good fact-keeping and which produce
noise.

photo_insights already carries model_version + backend, and
entity_facts.source_insight_id links to it, but:
  - source_insight_id is set post-loop, so chat-continuation and
    regenerated-insight facts lose the link.
  - JOINing per read is more friction than embedding provenance on
    the row itself.
  - Manual facts (POST /knowledge/facts) have no insight at all and
    need their own "manual" provenance marker.

Threading: execute_tool grows `model` + `backend` params, passed
from the three call sites (agentic insight loop, chat single-turn,
chat stream) using the loop-time `chat_backend.primary_model()` +
`effective_backend` already in scope. tool_store_fact stamps the
new fact accordingly; manual create_fact stamps backend="manual".
Legacy rows leave both NULL — pre-tracking data can't be back-
filled reliably from training_messages without burning compute.

Indexes are partial (WHERE NOT NULL) so legacy rows don't bloat
them, and "show me all facts from model X" stays fast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:05:14 -04:00
Cameron Cordes 85f3716379 knowledge: fact supersession + photo-date valid_from
Two Phase-2 followups in one commit since they're coupled at the
write path:

* Agent populates valid_from from the source photo's date_taken
  when calling store_fact. Loose semantics — date_taken is *evidence
  at that date*, not strictly when the fact started being true — but
  gives the curator a calendar anchor and pairs with supersession to
  close intervals cleanly. valid_until stays NULL (a single photo
  can't tell us when something stopped). Honours the existing
  upsert_fact dedup (corroborated facts keep their first-recorded
  valid_from).

* Supersession: new column entity_facts.superseded_by INTEGER
  (migration 2026-05-10-000200), new status value 'superseded',
  new DAO method supersede_fact, new HTTP endpoint
  POST /knowledge/facts/{id}/supersede.

  Marking an old fact as replaced by a new one atomically: flips
  status to 'superseded', sets superseded_by, and stamps
  valid_until from the new fact's valid_from (when not already
  set). delete_fact clears dangling supersession pointers in the
  same transaction so the column never points at a missing row —
  no FK because SQLite can't ALTER ADD with REFERENCES, but the
  DAO maintains the invariant.

Pairs with conflict detection from the previous slice: once the
old fact's valid_until is closed, its interval no longer overlaps
the new fact's, so they stop flagging — the supersede action
resolves the conflict.

Two tests pin the contract: supersede stamps valid_until from
new.valid_from while respecting an existing valid_until, and
deleting the supersedeR clears the dangling pointer while leaving
the old fact's 'superseded' status in place for history.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:47:06 -04:00
Cameron Cordes 01f5ad7527 knowledge: valid-time on facts + interval-aware conflict detection
Adds bitemporal support to entity_facts. Existing `created_at` is
transaction time (when we recorded the fact); the new
`valid_from` / `valid_until` BIGINT columns are valid time (when the
fact is/was true in the real world). NULL on either side = unbounded
on that side, both NULL = "always-true / unknown" — matches the
default state of every legacy row, no backfill needed.

The split matters for time-bounded predicates like
is_in_relationship_with / lives_in / works_at: recording the fact
once doesn't mean the relationship is still ongoing. Same predicate
across different windows ("lives_in NYC 2018-2020", "lives_in SF
2020-present") is no longer a conflict — the interval-aware check
in get_entity only flags pairs whose windows overlap. Facts with no
valid-time data still flag against everything (worst case for legacy
rows — user adds dates to suppress).

API surface:
- POST /knowledge/facts accepts optional valid_from / valid_until.
- PATCH /knowledge/facts/{id} accepts both with tri-state semantics:
  field omitted = leave alone, JSON null = clear to NULL, number =
  set. Implemented via a small serde helper around Option<Option>.
- GET /knowledge/entities/{id} surfaces both fields per fact and
  uses them in conflict detection.

Agent path (insight_generator) writes NULL/NULL for now — deriving
valid_from from the source photo's date_taken is slated for a
follow-up agent tool alongside Phase 2's supersession.

Test pins set + clear semantics via update_fact: setting both
bounds, leaving them alone on a subsequent patch, then clearing
valid_until back to NULL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:25:55 -04:00
Cameron Cordes bcd5312953 knowledge: detect same-predicate object conflicts at read time
GET /knowledge/entities/{id} now flags facts as `in_conflict` when
another active fact shares the same predicate but disagrees on the
object (entity id or text value). Pure read-time computation in the
handler — group facts by predicate, distinct-object count > 1 flags
all members. No schema change; same shape as `is_current` on photo
insights.

The flag is intentionally a *signal*, not a hard constraint. Some
predicates are legitimately multi-valued (friend_of, tagged_in,
appears_in) — the curator UI surfaces the amber accent and lets the
user reject the stale fact, accept both, or supersede one later
once the supersession column lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:14:58 -04:00
Cameron Cordes 0b8478a5e4 knowledge: list sort + persona-scoped fact_count per entity
Two related additions to /knowledge/entities:

- New EntitySort enum (UpdatedDesc default, NameAsc, FactCountDesc)
  surfaced via `?sort=updated|name|count`. NameAsc clusters near-
  duplicate names so dupes stand out at a glance; FactCountDesc
  surfaces heavily-used entities and demotes 0-fact noise to the
  bottom.

- New `list_entities_with_fact_counts` DAO method that returns each
  entity alongside a persona-scoped count of its non-rejected facts
  (subject side). Persona scope follows X-Persona-Id via the
  existing resolve_persona_filter chain — Single filters on
  (user_id, persona_id), All unions across the user's personas.
  Implemented as one raw SQL query with a LEFT JOIN to a fact-count
  subquery and ORDER BY tied to the chosen sort, so count-sort needs
  no second round trip.

The agent's existing list_entities call site is unchanged — it
doesn't need persona-scoped counts and the trait method stays cheap.
EntitySummary grows an Option<i64> fact_count (skip_serializing_if
none) so PATCH responses stay shaped as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:04:13 -04:00
Cameron Cordes 0e2b18224f knowledge: pre-delete relational facts so entity delete succeeds
DELETE /knowledge/entities/{id} was 500ing on any entity that was the
object of a relational fact. entity_facts.object_entity_id has
ON DELETE SET NULL, but the table also has
CHECK (object_entity_id IS NOT NULL OR object_value IS NOT NULL) —
purely relational facts (subject + predicate + object_entity_id, no
object_value, like "Alice is_friend_of Bob") would have both NULL
after SET NULL fired, the CHECK would abort, and the whole DELETE
would fail with a CHECK violation. The user just saw QueryError
because the DAO swallowed the diesel error string.

Wrap delete_entity in a transaction that first deletes facts where
the entity is the object AND object_value is null, then deletes the
entity. Surviving siblings (typed facts about the entity as subject)
are CASCADE'd by the FK as before. Also start surfacing the actual
diesel error in a warn log before collapsing to DbErrorKind so future
similar issues don't masquerade as the opaque QueryError.

A schema-level fix (changing object FK to ON DELETE CASCADE via a
table-rebuild migration) is the cleaner long-term resolution and is
slated for Phase 2; the DAO-side pre-delete is sufficient and less
invasive in the meantime.

Test pins the contract: a relational fact pointing at the deleted
entity is removed, an unrelated typed fact about an unrelated entity
survives, and the entity itself is deleted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:44:38 -04:00
Cameron Cordes f7ce3d2b22 knowledge: include library_id in photo_links response
The PhotoLinkDetail in /knowledge/entities/{id} was dropping the
library_id field, leaving consumers no way to construct a
content-routed thumbnail URL. Apollo's curation screen was falling
through to library=0 (the FastAPI default) and getting 400s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:19:37 -04:00
Cameron Cordes d7aee4f228 knowledge: cosine dedup, fact create endpoint, recall nudge
Phase 1 of the knowledge curation work. Three small server-side changes
to support an Apollo-side curation surface and reduce the agent's near-
duplicate output rate going forward:

- upsert_entity grows an embedding-cosine fallback after the exact name
  match misses. New entities whose embedding sits above
  ENTITY_DEDUP_COSINE_THRESHOLD (default 0.92) against any same-type
  active entity collapse onto the existing row. Eliminates the Sarah /
  Sara / Sarah J. trio the FTS5 prefix check was missing.
- POST /knowledge/facts symmetric with the existing PATCH/DELETE so the
  curation UI can create facts directly. Persona-scoped via X-Persona-Id;
  validates subject (and optional object) entity existence; reuses
  KnowledgeDao::upsert_fact so corroboration semantics match the agent
  path.
- One sentence in build_system_content telling the agent to call
  recall_entities before store_entity when a name resembles something
  already known. Cheap; complements the DAO-layer guard.

Includes upsert_entity_collapses_near_duplicate_by_embedding test
covering both the collapse-on-near-match path and the don't-collapse-on-
unrelated-embedding path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:16:05 -04:00
cameron 827a78dd79 Merge pull request 'feature/persona-fk-and-guard' (#90) from feature/persona-fk-and-guard into master
Reviewed-on: #90
2026-05-10 18:42:27 +00:00
Cameron Cordes 08a5f46be1 chat: scope insight lookup by library_id to fix regen-shadow bug
When a photo exists in more than one library and the user
regenerates its insight from library A's chat, the regenerate
streams cleanly, store_insight flips library A's old row to
is_current=false, and inserts a new is_current=true row tagged
(library A, rel_path). On the next history fetch the user sees
their old transcript — the regenerate appears to vanish.

The cause: get_insight(file_path) filters on rel_path + is_current
only, so library B's untouched is_current=true row for the same
rel_path satisfies the query and gets returned by SQLite's .first()
ahead of A's new row. Because get_insight is also what
chat_turn_stream uses to decide bootstrap vs. continuation, the
next chat turn after the shadow hit also routes against the
wrong insight, so update_training_messages corrupts library B's
transcript with library A's chat.

Fix: add get_current_insight_for_library(library_id, file_path)
filtered on (library_id, rel_path, is_current=true) and route the
chat surface (load_history, chat_turn{,_stream}, rewind_history)
through it. load_history falls back to the cross-library
get_insight when the scoped lookup misses — preserves the
"scalar data merges across libraries" intent for the case where
the active library has no insight but another does. The path-only
get_insight stays for callers that don't have library context
(populate_knowledge, the photo-grid metadata fetch).

chat_history_handler stops dropping the parsed library on the
floor and threads it through. Single-library deploys see no
behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:03:41 -04:00
Cameron Cordes b9d9ba0320 chat: route search_messages({date}) to get_sms_messages
When the LLM calls search_messages with { date, limit } and no
query, it's making the predictable mistake of conflating the two
"messages"-shaped tools. The previous behaviour returned an error
that pointed it at get_sms_messages — correct, but burning a turn
on the misroute. Long photo-chat threads where the user asks
"what was happening that weekend?" hit this on small models
roughly half the time.

Now the date-string-without-query case transparently dispatches
to get_sms_messages with the same args (date / limit / days_radius
/ contact name all pass through unchanged) and prepends a short
"(Note: routed to get_sms_messages — prefer it directly next time)"
to the result. The model sees real data on its first try while
still learning the right tool for next time. Cases that don't have
a get_sms_messages equivalent (numeric contact_id, or start_ts /
end_ts windows) keep the original error so the model knows to
either supply a query or restructure its call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:48:13 -04:00
Cameron Cordes fbd769e475 personas: composite FK + built-in update guard
Two persona-infrastructure correctness fixes that go together because
the second one (FK with CASCADE) requires the first (preventing the
persona row from being mutated out from under its facts).

1. update_persona handler refuses name/systemPrompt edits to built-ins
   (409). includeAllMemories stays editable — that's a per-user
   preference, not the persona's identity. Mirrors the existing
   delete_persona guard. The DAO is intentionally permissive so the
   guard sits at the HTTP layer; persona_dao test pins that contract.

2. Migration 2026-05-10 adds user_id to entity_facts and a composite
   FK (user_id, persona_id) -> personas(user_id, persona_id) ON DELETE
   CASCADE. This closes two issues at once:

   - Persona orphans: deleting a custom persona used to leave its
     facts dangling forever, readable only via PersonaFilter::All.
     CASCADE now wipes them with the persona row.

   - Multi-user fact leakage: PersonaFilter::Single("default") used
     to surface every user's default-scoped facts. PersonaFilter is
     now { user_id, persona_id } and all read paths
     (get_facts_for_entity, list_facts, get_recent_activity) filter
     on user_id first. upsert_fact's dedup key extends to user_id so
     identical claims under shared persona names from different
     users no longer corroborate-bump each other's confidence.

   - user_id threads from Claims.sub.parse::<i32>().unwrap_or(1) at
     the chat / insight handlers through ChatTurnRequest, the
     streaming agentic loop, execute_tool, and into the leaf tools
     (tool_store_fact, tool_recall_facts_for_photo). The ".unwrap_or(1)"
     accommodates Apollo's service token whose sub is non-numeric on
     legacy mints.

   - Backfill picks the smallest user_id matching each legacy fact's
     persona_id so the FK holds for already-stored rows.

Five new knowledge_dao tests with FK-on connection: persona scoping
isolation, All-variant union per-user, dedup not crossing users,
CASCADE delete, FK rejection of unknown personas. Plus
dao_update_does_not_block_built_ins documenting where the
HTTP-layer guard lives.

Apollo coordinates separately — the matching changes there add the
/api/personas proxy and start sending persona_id on photo-chat turns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:30:35 -04:00
cameron 79a1168724 Merge pull request 'faces: add person_id filter to /faces/embeddings; remove tag-bootstrap' (#89) from feature/faces-tab into master
Reviewed-on: #89
2026-05-10 15:49:18 +00:00
Cameron Cordes a079065ae9 faces: add person_id filter to /faces/embeddings; remove tag-bootstrap
Pairs with the Apollo FACES-tab change. The new
POST /api/persons/{id}/similar-unassigned route on Apollo needs to
fetch one person's embeddings cheaply to compute the centroid;
adding a person_id query param to /faces/embeddings keeps that to a
single round-trip instead of paging the whole detected set
client-side. When both person_id and unassigned=true are supplied,
person_id wins (the explicit filter is the more specific intent).

Tag-bootstrap removal: bootstrap_candidates_handler,
bootstrap_persons_handler, /persons/bootstrap and
/tags/people-bootstrap-candidates route registrations, and the
heuristic helpers (is_plausible_name_token, looks_like_person) plus
their tests. Only Apollo called these; the migration is complete.
The persons.created_from_tag column stays - it's informational on
existing rows and removing it would be a destructive migration for
no benefit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:30:37 -04:00
cameron 25233904aa Merge pull request 'personas: elevate to server with per-persona fact scoping' (#88) from feature/persona-knowledge-segmentation into master
Reviewed-on: #88
2026-05-10 03:44:26 +00:00
cameron 8c377324a1 Merge pull request 'video: handle unknown/short durations in thumb + preview gen' (#87) from fix/video-thumb-preview-edge-cases into master
Reviewed-on: #87
2026-05-10 03:12:58 +00:00
Cameron Cordes 5476ed8ac4 video: handle unknown/short durations in thumb + preview gen
`get_duration_seconds` now returns `Option<f64>` and falls back from
`format=duration` to `stream=duration`. Empty stdout no longer
parse-panics with "cannot parse float from empty string", which was
poisoning the preview-clip row with status=failed and re-queueing every
full scan (notably for GoPro LRV files). `generate_preview_clip` handles
the unknown-duration case by transcoding the whole file (capped at 10s).

`generate_video_thumbnail` seeks to ~50% of the probed duration instead
of a hardcoded `-ss 3`, with a first-frame fallback when the probe
returns nothing. Fixes the loop where short Snapchat clips (<3s) got
"missing thumbnail" logged on every scan because ffmpeg exited 0
without writing a frame, and never wrote the .unsupported sentinel
either.

Adds unit tests for `parse_ffprobe_duration` covering the empty-output,
N/A, multi-line, non-positive, and non-finite cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:08:16 -04:00
cameron 7350f1916a Merge pull request 'fix/manual-date-update' (#86) from fix/manual-date-update into master
Reviewed-on: #86
2026-05-10 02:53:20 +00:00
Cameron Cordes 9871c685b4 date-override: cargo fmt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:23:11 -04:00
Cameron Cordes 108bbeb029 date-override: union semantics across libraries + slash forms
The date-override path used to look up `image_exif` strictly by
`(library_id, rel_path)` with only the forward-slash form, while
`/image/metadata`'s `get_exif` falls back across libraries and tries
both slash forms. A photo whose row sat under a different library_id
than its filesystem-resolved one — or whose rel_path was stored with
backslashes — rendered fine in the modal but 404'd on save.

`set_manual_date_taken` / `clear_manual_date_taken` now share a
`locate_image_exif_row` helper that mirrors `get_exif`'s union
semantics (scoped lookup first, library-agnostic fallback by rel_path
in both slash forms), then update by primary key so the write hits
exactly the row read. Inner anyhow errors are logged with
`(library_id, rel_path)` so the next failure mode is debuggable.

Handler-side: `resolve_library_param` errors no longer silently fall
back to the primary library (which would have masked the original bug
with a different "row not found"); a malformed library param now
returns 400. New `DbErrorKind::NotFound` lets the handler distinguish
genuine misses (404) from real DB failures (500).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:21:25 -04:00
Cameron Cordes 3e2f36a748 personas: elevate to server with per-persona fact scoping
Move personas off the mobile client into ImageApi as first-class
records, and scope entity_facts by persona so each one builds its own
voice over a shared entity graph. The new include_all_memories flag
lets a persona opt back into the full hive-mind pool for human
browsing of /knowledge/*; agentic generation always stays in-voice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:59:20 -04:00
cameron 55a986c249 Merge pull request 'feature/streaming-insights' (#85) from feature/streaming-insights into master
Reviewed-on: #85
2026-05-09 20:57:16 +00:00
cameron c52a646be2 Merge pull request 'memories: restore early-era Snapchat unix-epoch filenames' (#84) from feature/snapchat-early-era-dates into master
Reviewed-on: #84
2026-05-08 20:23:35 +00:00
Cameron Cordes d32a7d7c3a memories: restore early-era Snapchat unix-epoch filenames
The recent blanket "snapchat-" prefix denylist (43f8f83) rejected ALL
Snapchat-prefixed filenames from timestamp parsing, which fixed the
sequential-ID false positives but also broke real unix-second
filenames from Snapchat's early era. `Snapchat-1383929602.jpg`
(2013-11-08 16:53:22 UTC) now falls through to fs_time — and on files
with broken filesystem metadata, fs_time pins to 1970.

Replace the blanket prefix denial with a tighter discriminator:
  - exactly 10 captured digits AND timestamp >= 2011-09-23 (Snapchat
    launch) → real unix epoch, accept
  - any other length under this prefix → sequential ID, reject

This keeps the existing rejections intact:
  Snapchat-1021849065.mp4          → 10 digits, 2002 < launch → reject
  Snapchat-1751031586660373917.jpg → 19 digits truncates to 16 → reject
And restores the regression case:
  Snapchat-1383929602.jpg          → 10 digits, 2013 ≥ launch → accept

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:22:57 -04:00
Cameron Cordes 3699e059a2 insight-chat: include Date taken + GPS in bootstrap photo context
The bootstrap system message gave the model a file path and (in
hybrid mode) a visual description, but no temporal anchor. Models
defaulted to today's date when calling get_sms_messages — Nov 2014
photos were getting "2024-03-11" passed as `date`, missing every
historical message and leading the model to confidently misreport
context.

This commit folds two more EXIF-sourced facts into the
--- PHOTO CONTEXT --- block:

  Date taken: <YYYY-MM-DD or "unknown">
  GPS: <lat, lon to 4dp>           (omitted when no GPS)

Resolution waterfall for date_taken matches the documented canonical
date pipeline at the EXIF / filename steps, but intentionally stops
short of the fs-time fallback `generate_agentic_insight_for_photo`
uses — for chat we'd rather show "unknown" than mislead the model
with an inode mtime. GPS is taken straight from EXIF when both
lat/lon are populated; absent GPS suppresses the line entirely so
the model doesn't hallucinate coordinates.

InsightGenerator gains a `fetch_exif(file_path)` accessor (crate-
visible) so the chat service doesn't need its own ExifDao plumbing.

build_bootstrap_system_message picks up two new params (date,
gps); existing tests updated and 5 new tests cover:
- date present / absent / waterfall (EXIF wins, filename fallback,
  None when neither source has it)
- GPS present / absent
- ordering (path → date → visual)

Total insight_chat unit tests: 33 (up from 27).
2026-05-08 11:14:39 -04:00
Cameron Cordes a0ec1a5080 insight-chat: photo context belongs in system msg, not user turn
After refresh, the rendered transcript was showing two unwanted
artifacts in the initial user bubble:

  Photo file path: pics/DSC_5171.jpg
  please tell me about this photo and what was going on around it

  Please write your final answer now without calling any more tools.

Two distinct bugs:

1. Bootstrap was prepending `Photo file path: <path>` (and, in
   hybrid mode, the visual description block) into the user-turn
   content. The model needed it to call file_path-keyed tools, but
   the user could see it in their own bubble on replay.

2. The no-tools fallback ("Please write your final answer now…")
   was a synthetic user message we never stripped from history,
   so it persisted into training_messages, rendered as a second
   user bubble, AND wiped the prior tool-call accumulator inside
   load_history (user-turn handler clears pending_tools), which
   is why the tool invocations disappeared from the assistant
   bubble after refresh.

Fixes:

- New `build_bootstrap_system_message` helper composes the persona
  with a `--- PHOTO CONTEXT ---` block (path + optional visual
  description). Lives in the system message, not the user turn.
  The user's bubble shows only what they typed.
- Streaming agentic loop's no-tools fallback now records its
  insertion index and removes the synthetic user prompt from
  `messages` after the model responds. Final assistant content
  stays — it reads coherently on replay without the synthetic
  prompt above it. Applies to both bootstrap and continuation.

3 new tests cover the system-message composer (path-only, with
visual block, persona-trim). Total insight_chat unit tests: 27.
2026-05-08 11:07:03 -04:00
Cameron Cordes 24ecf2abd4 insight-chat: prepend Photo file path: <path> to bootstrap user turn
Bug: bootstrap user_content was just the user's typed message (plus
the hybrid visual description). Tools that take a file_path arg —
recall_facts_for_photo, get_file_tags, get_faces_in_photo — had no
way to learn the canonical path. Small models would invent
placeholders like "input_file_0.png" or call the tool with a name
guessed from a hidden multimodal input handle, neither of which
matched any real photo.

Fix: prepend a single-line "Photo file path: <normalized>\n\n" block
to user_content. Same shape generate_agentic_insight_for_photo
already uses for non-chat callers — kept the bootstrap minimal
(no date / GPS / tags pre-stuffing; the agentic loop can fetch
those via tools when needed).

Hybrid still injects the visual description block between the path
block and the user message; local mode just gets path + user text.
2026-05-08 10:59:35 -04:00
Cameron Cordes a29ff406a1 insight-chat: extract bootstrap resolution helpers + unit-test them
resolve_bootstrap_system_prompt and resolve_bootstrap_backend run on
every bootstrap turn — they pick the persisted system prompt and the
chosen backend label. They were inline conditionals before; pulling
them out makes the rules testable without spinning up the full
streaming stack.

9 new tests cover:
- system prompt fallback to BOOTSTRAP_DEFAULT_SYSTEM_PROMPT for None,
  empty string, whitespace-only
- supplied non-empty prompts pass through verbatim, with interior
  newlines / spacing preserved (Apollo personas use multi-line tool
  listings)
- backend defaults to "local" for None / empty
- "local" / "hybrid" accepted case-insensitively with edge-trim
- unknown labels return a descriptive error

Total insight_chat tests: 24 (up from 15). No behaviour change.
2026-05-08 10:56:22 -04:00
Cameron Cordes 928efe49f9 insight-chat: bootstrap insight on first Discuss message + regenerate flag
Tap-Discuss-on-no-insight previously failed silently: ImageApi's
/insights/chat/stream required an existing agentic insight, errored
when missing, and emitted the failure as `event: error` — which the
frontend SSE consumer ignored (it listens for `error_message`).

This commit closes both gaps with a server-side state machine:

- /insights/chat/stream now branches on insight presence. Missing
  insight (or `regenerate: true` in the body) → bootstrap path:
  builds [System(req.system_prompt), User(req.user_message + image)],
  runs the agentic loop, generates a title, persists a new row via
  store_insight (which auto-flips priors). Existing insight →
  continuation path (unchanged behaviour).
- New `regenerate: bool` request field forces bootstrap even when an
  insight exists. Takes precedence over `amend`.
- `done` SSE payload field-name alignment with Apollo's frontend
  convention: prompt_eval_count → prompt_tokens, eval_count →
  eval_tokens, num_ctx echo added.
- `amended_insight_id` semantics broaden — now populated whenever the
  turn produced a new row (bootstrap, regenerate, or amend). Existing
  amend clients keep working unchanged; new clients get the new row's
  id for free.
- `event: error` → `event: error_message` so frontend errors stop
  silently dropping.

Refactor: extracted run_streaming_agentic_loop, build_chat_clients,
and generate_title as shared helpers between bootstrap and
continuation. Continuation path's outer logic moves to
run_continuation_streaming with no behaviour change.

Mobile-ready: any client (Apollo backend, mobile, future) sends one
request to /insights/chat/stream and gets the right path. Apollo's
proxy stays a dumb pipe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:41:50 -04:00
cameron bdafd39546 Merge pull request 'feature/insight-chat-improvements' (#83) from feature/insight-chat-improvements into master
Reviewed-on: #83
2026-05-07 22:19:12 +00:00
Cameron Cordes 8bd1a85070 insight-chat: cargo fmt sweep on the get_faces_in_photo additions
Single-line dao lock + reordered faces import. No logic changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:53:31 -04:00
Cameron Cordes 6f0c15d0c5 insight-chat: code-review polish on get_faces_in_photo
- Drop redundant `use anyhow::Context` inside has_any_faces (already
  imported at the module level).
- Drop dead `.unwrap_or("?")` on bound faces — the vec is filtered to
  is_some() so the fallback can never fire.
- Reorder the face_dao constructor param + initializer to match the
  struct declaration (between tag_dao and knowledge_dao). Update both
  state.rs call sites and populate_knowledge.rs to match.
- Hold face_dao lock once across the library-resolver loop instead of
  reacquiring per iteration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:48:22 -04:00
Cameron Cordes b64a5bec28 insight-chat: add get_faces_in_photo agentic tool
The LLM had no path to see face_detections data — get_file_tags
returns user-applied tags, but a face that's been detected and bound
to a person via the embedding-cluster auto-bind path doesn't always
have a matching tag. The new tool joins face_detections with persons
by content_hash and returns bound names + bboxes, plus unidentified
faces (so smaller models can count people in the photo without
inferring from a visual description).

Gated on face_detections being non-empty via the same has_any_*
pattern as daily_summaries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:43:16 -04:00
Cameron Cordes 388eb22cd2 Remove full plan file, just keep spec 2026-05-07 17:29:04 -04:00
Cameron Cordes eef41d4172 thumbnails: align video ffmpeg args with the image path so non-yuvj420p sources work
The bare 'ffmpeg -ss 3 -i in -vframes 1 -f image2 out' command failed on
sources whose decoded pix_fmt isn't yuvj420p (e.g. older Samsung phone
videos in yuv420p). With no -vf filter chain, the decoded frame goes
straight to the mjpeg encoder, which rejects it with 'Non full-range
YUV is non-standard' and exits non-zero.

generate_image_thumbnail_ffmpeg already handles the same class of
source for HEIC/RAW by adding -vf scale=200:-1 -c:v mjpeg — the filter
chain lets ffmpeg auto-insert the pix_fmt converter the encoder needs.
Adopt the same args here. Side benefit: video thumbnails are now 200px
wide on disk, matching image thumbnails (previously full-resolution).

Pre-existing .unsupported sentinels for videos that hit this failure
will need to be deleted manually to retry — they're under
$THUMBNAILS/<lib_id>/.../*.unsupported.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:20:05 -04:00
Cameron Cordes b42acbb3f3 fmt: cargo fmt sweep across drifted files
No behavior change — purely whitespace/line-break cleanup that had
accumulated since the last format run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:42:41 -04:00
Cameron Cordes 2a273a3ed9 thumbnails: stop video failures from re-logging every watcher tick
generate_video_thumbnail used .output().expect(...), which only catches
spawn failure — non-zero ffmpeg exits were silently discarded. With no
thumbnail and no .unsupported sentinel left behind, the watcher
re-detected the file as missing every quick-scan tick and re-logged
"New file detected (missing thumbnail)" forever.

Mirror the image branch: return io::Result, check status.success(),
and write the sentinel from create_thumbnails on failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:41:24 -04:00
Cameron Cordes a8433c2e01 insight-chat: document the new system_prompt field in CLAUDE.md
Add system_prompt to the /insights/chat body schema with a one-paragraph
note on the append-vs-amend semantics so future readers find the
contract alongside the rest of the chat-continuation docs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:26:32 -04:00
Cameron Cordes 1cdc0f6eb9 insight-chat: drop the dead SmsApiClient::search_messages wrapper
The post-PR-4 delegation kept it as a convenience for callers that
don't filter by contact, but nothing actually uses it. Delete to clear
the dead_code warning. search_messages_with_contact remains as the
single entry point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:10:31 -04:00
Cameron Cordes e539c083c9 insight-chat: code-review polish on the tool-gating PR
- search_messages now delegates to search_messages_with_contact(.., None)
  so the two methods share a single HTTP path. Drops the dead-code
  warning and the ~30-line duplication.
- DailySummaryDao gains has_any_summaries (LIMIT 1 existence probe)
  used by current_gate_opts; the SELECT COUNT(*) get_total_summary_count
  added in the prior commit is removed (it had no other caller).
- current_gate_opts doc comment corrected to describe what the probes
  actually do.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:07:57 -04:00
Cameron Cordes f50d32667b insight-chat: ToolGateOpts + per-tool description rewrites
Tools whose backing tables are empty (calendar, location_history,
daily_summaries) drop out of the catalog so the LLM doesn't waste
iteration budget calling them only to receive "no results found".
Vision and apollo gates already existed; this generalizes the pattern.

search_messages gains start_ts/end_ts/contact_id filters (date filter
is a client-side post-filter; SMS-API only accepts contact_id natively
on the search endpoint).

Descriptions follow a consistent convention: one sentence (what +
when), param semantics, examples for tools with non-obvious param
choices. No more all-caps headers, no more identity-prescriptive
language inside descriptions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:56:58 -04:00
Cameron Cordes b02da0d0cc insight-chat: code-review polish on the days_radius fix
- Bind effective_radius once in fetch_messages_for_contact so the log
  output and window math share a single source of truth for the clamp.
- Clamp tool-supplied days_radius to [1, 30] at the tool boundary so a
  runaway LLM value can't produce a thousand-day window.
- Split the negative-input test into a real negative-input case
  alongside the zero-input case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:47:46 -04:00
Cameron Cordes 659e7bd973 insight-chat: get_sms_messages tool now honors days_radius
The agentic tool definition advertised a days_radius parameter but
sms_client::fetch_messages_for_contact was hardcoded to ±4 days,
silently ignoring whatever value the LLM chose. Plumb the parameter
through; default 4 retained at the tool level for back-compat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:42:42 -04:00
Cameron Cordes 428f24b0f8 insight-chat: code-review polish on the chat system_prompt override
- Trim the override input once via Option::map(str::trim).filter(...).
- Use matches!() in restore_system_prompt_override's Prepended arm so
  it reads consistently with the Replaced arm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:40:04 -04:00
Cameron Cordes faa289882f insight-chat: per-turn system_prompt override on chat continuation
Append mode: applied ephemerally — original system message restored
before persistence so re-opens see the baked persona. Amend mode:
override stays in place and becomes the new insight row's system
message. Pattern mirrors annotate_system_with_budget.

Adds system_prompt field on both ChatTurnHttpRequest and ChatTurnRequest;
plumbs through chat_turn and chat_turn_stream identically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:34:08 -04:00
Cameron Cordes 177187f6a2 insight-chat: code-review polish on the system-prompt split
- Use Option::map instead of manual match-on-Option (drops clippy::manual_map).
- Drop redundant `max_iterations = max_iterations` from the format! call.
- Use captured identifiers consistently in the user_content format!.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:27:59 -04:00
Cameron Cordes 8ae4099d46 insight-chat: split generation system prompt into identity + procedural blocks
The framework no longer asserts "you are a personal photo memory
assistant" alongside a user-supplied custom_system_prompt — the
persona is the authoritative identity. The procedural block (tool-use
guidance, iteration budget) stays identity-free.

The user message also stops asking for "a detailed insight with a
title and summary" since the title is regenerated post-hoc anyway and
the wording was constraining voice for no data-model benefit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:20:45 -04:00
Cameron Cordes 204428b0c0 insight-chat: implementation plan for the spec
Five sequenced PRs:
  1. Split generation system prompt + neutralize user message
  2. system_prompt field on chat request (ephemeral / amend-persisted)
  3. fetch_messages_for_contact honors days_radius
  4. ToolGateOpts + per-tool description rewrites + search_messages
     gains start_ts/end_ts/contact_id
  5. FileViewer-React: persona system_prompt on every turn + style note

Each PR independently mergeable. Tests inline TDD per task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:15:09 -04:00
Cameron Cordes fbece0ba9a insight-chat: design for tool catalog, system prompt, and SMS fixes
Lays out the cycle: split generation system prompt into identity vs
procedural blocks so personas drive voice/shape, add per-turn
system_prompt override on chat (ephemeral in append mode, persisted
on amend), gate optional tools on data presence, and fix the
days_radius bug in get_sms_messages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:04:07 -04:00
cameron 22e157411c Merge pull request 'date_resolver: drop -fast2 so MP4 moov-at-end files resolve' (#82) from fix/exiftool-mp4-moov-trailer into master
Reviewed-on: #82
2026-05-07 16:42:08 +00:00
Cameron Cordes c128596470 date_resolver: drop -fast2 so MP4 moov-at-end files resolve
For QuickTime/MP4 files whose `moov` atom sits at the end of the
file (non-faststart — common for Snapchat exports and any MP4
muxed without `-movflags +faststart`), `-fast2` causes exiftool
to skip the trailer and return no `CreateDate` /
`MediaCreateDate`, dropping the resolver to the `fs_time`
fallback for files that actually have a real capture date.

Reported cases:
  Snapchat-477624257.mp4
    fs_time: 2026-05-04 (today, file was just modified)
    real:    QuickTime CreateDate 2018-09-02
  action_compound_cc92e65b709d1deb895b4c2a9484fc6a.mp4
    fs_time: 2026-05-04
    real:    MediaCreateDate 2018-03-01

The waterfall pre-filters to files kamadak-exif couldn't read, so
the JPEG fast-path is already covered without `-fast2`. Paying
full-scan cost on the residual is the right trade. The per-tick
drain re-resolves `source = 'fs_time'` rows, so existing rows
recover automatically on the next watcher tick after deploy — no
SQL migration needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:40:50 -04:00
cameron ac8d17fb22 Merge pull request 'memories: deny Snapchat-prefixed filenames from timestamp parsing' (#81) from feature/filename-date-snapchat-denylist into master
Reviewed-on: #81
2026-05-07 16:20:06 +00:00
Cameron Cordes 43f8f83d80 memories: deny Snapchat-prefixed filenames from timestamp parsing
Snapchat assigns sequential IDs that happen to overlap real epoch
values, so the 10-16 digit timestamp regex matched and produced
2002-era dates for files actually saved in 2016/2021. The digits
themselves are indistinguishable from a unix timestamp, so we
dispatch on the source-app prefix instead. Case-insensitive,
extensible for future apps that exhibit the same pattern.

Reported cases:
  Snapchat-1021849065.mp4          → 2002-05-19 (actual 2021)
  Snapchat-1751031586660373917.jpg → 2002-09-09 (actual 2016)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:17:40 -04:00
cameron e55f6a5961 Merge pull request 'memories: reject implausible filename-derived timestamps' (#80) from feature/filename-date-plausibility into master
Reviewed-on: #80
2026-05-07 16:02:50 +00:00
Cameron Cordes feaae9b6d3 memories: reject implausible filename-derived timestamps
Filenames like `000227580005.jpg` (film-scan ID) and
`IMG_21323906751390.jpeg` were matched by the 10-16 digit timestamp
regex and resolved to 1970 / 2037, then written into
`image_exif.date_taken` with `source = 'filename'`. EXIF-less
photos showed up under those bogus dates everywhere date_taken is
read.

Two new guards in `extract_date_from_filename`:
- leading zero → reject (real epoch values don't have one at any
  sane resolution).
- resolved year outside [1995, now+1y] → reject.

Both let the date_resolver waterfall fall through to fs_time,
which is a much better proxy for content age than a fake epoch
date. Regression tests cover the two reported filenames.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:02:07 -04:00
cameron 95e21c8128 Merge pull request 'feature/manual-date-override' (#79) from feature/manual-date-override into master
Reviewed-on: #79
2026-05-07 15:10:37 +00:00
Cameron Cordes 7e1c4ab318 backfill_date_taken: surface the actual diesel error in warnings
The DAO swallowed every diesel::update failure as a flat
`anyhow!("Update error")`, then trace_db_call further reduced it to
`DbError { kind: UpdateError }`. Operators saw "update failed for lib
2 Snapchat/foo.mp4: DbError { kind: UpdateError }" with no clue why
(constraint violation? type mismatch? row vanished mid-flight? DB
locked?).

Two changes:
- Preserve the diesel error in the anyhow chain along with the input
  params (lib, rel_path, date_taken, source) so the cause is visible.
- Log the chain at warn-level inside the DAO before the trace wrapper
  collapses it to DbErrorKind::UpdateError, so the warning at the
  call site finally has something diagnosable next to it.
- Treat zero-row updates as a debug-level "row likely retired by the
  missing-file scan" rather than a hard failure — that case is benign
  and shouldn't poison the drain's error tally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:07:17 -04:00
Cameron Cordes 65af7d999e memories: parse filename dates as UTC, not server local
`extract_date_from_filename` was calling `Local::from_local_datetime`
on the parsed YYYY-MM-DD-HH-MM-SS components, then `.timestamp()` was
shifting the result by the SERVER's TZ offset to produce real UTC
seconds. That made filename-sourced timestamps disagree with EXIF-
sourced timestamps by hours: kamadak-exif's `DateTimeOriginal` is a
naive string parsed AS-IF-UTC (the project's load-bearing
"naive local reinterpreted as UTC" convention), and Apollo's photo
matcher re-anchors that naive value through the BROWSER's TZ when
matching to the track. Anything stamped in server-local instead got
double-shifted on its way through the matcher and through any
`formatNaive*` display path on the client.

Visible symptom in the Apollo DETAILS modal: a photo's CURRENT date
read correctly (1:25 AM via exif) while FROM FILENAME read 4 hours
ahead (5:25 AM in EDT) for the same `IMG_20160710_012515.jpg`.

Switch to `Utc::from_utc_datetime` so `.timestamp()` returns the
wall-clock-as-UTC unix seconds — same convention as the EXIF path.
The /memories endpoint, the canonical-date waterfall (which feeds
`image_exif.date_taken` for filename-only files), and Apollo's
DETAILS modal `filename_date` field all now line up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:43:18 -04:00
Cameron Cordes 16d6586b7d exif: GET /image/exif/full — exiftool dump for the DETAILS modal
The curated `image_exif` columns are a small slice of what exiftool
can read (camera/lens/GPS/capture/dates). Apollo's DETAILS modal wants
to surface everything — white balance, metering, MakerNotes, IPTC,
ICC profile, Composite tags, the lot — for an operator inspecting a
photo's provenance.

`read_full_exif_via_exiftool(path)` shells out to `exiftool -j -G -n`:
JSON output, group-prefixed keys (`EXIF:Make`, `MakerNotes:LensInfo`),
numeric values (callers can reformat). Spawned via web::block to keep
it off the actix worker — RAW with rich MakerNotes can take a few
seconds.

The endpoint is on-demand only; the indexer / file watcher does NOT
call it. Falls back to 503 with a clear message when exiftool isn't
on PATH so Apollo can render an "install exiftool" hint. Multi-library
union resolution mirrors set_image_gps / get_file_metadata.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:42:41 -04:00
Cameron Cordes 832b50d587 image_exif: manual date_taken override (set/clear endpoints)
Add `POST /image/exif/date` and `POST /image/exif/date/clear` so an
operator can correct a row whose canonical-date waterfall landed on the
wrong value (camera clock reset, fs_time fallback for a copied-from-
backup file, etc). New `original_date_taken` / `original_date_taken_source`
columns snapshot the prior value on first override so revert is lossless.

The waterfall source set is now `'exif' | 'exiftool' | 'filename' | 'fs_time' | 'manual'`.
The existing `idx_image_exif_date_backfill` partial index already filters
to `date_taken IS NULL OR date_taken_source = 'fs_time'`, so manual rows
are naturally excluded from the per-tick drain — no index change needed.

`ExifMetadata` now exposes `date_taken_source` + originals so a UI can
render "manually set; was X via filename".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:26:43 -04:00
cameron 2acc525e73 Merge pull request 'otel: revert HTTP transport, keep gRPC' (#78) from fix/otlp-revert-to-grpc into master
Reviewed-on: #78
2026-05-06 22:36:09 +00:00
Cameron Cordes ecd49fd053 otel: revert HTTP transport, keep gRPC
The HTTP/protobuf exporter never sent any traffic in prod (tcpdump
on port 4318 showed nothing) despite the receiver path being correct
and the bridge wiring being intact (logs reached journalctl via the
stdout exporter). Likely the BatchLogProcessor + reqwest-client combo
isn't getting the right runtime context, but debugging that on a live
deployment isn't worth holding up the rest of the speedups.

Restoring grpc-tonic transport so prod observability comes back. The
remaining build-time wins on this branch (mold linker, system sqlite3,
profile.dev tweaks, lockfile-only dep refresh) deliver most of the
original savings without touching telemetry. Operator: revert
OTLP_OTLS_ENDPOINT in prod from port 4318 back to 4317.

HTTP transport remains a viable follow-up — needs to be debugged
against a local SigNoz instance with internal SDK error visibility
enabled, on its own branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:33:37 -04:00
cameron c7bd2226cc Merge pull request 'build: speed up debug compile loop' (#77) from feature/build-time-speedups into master
Reviewed-on: #77
2026-05-06 21:41:19 +00:00
Cameron Cordes f73db58771 build: speed up debug compile loop
- Drop libsqlite3-sys 'bundled' on Linux/macOS so the SQLite C source
  isn't recompiled every clean build; Windows keeps 'bundled' via a
  cfg(windows) target override.
- Switch opentelemetry-otlp from grpc-tonic to http-proto + reqwest-client.
  Removes the tonic + h2 + hyper-h2 stack from the build graph; reqwest
  was already a dependency. Updates otel.rs to call .with_http().
- Add [profile.dev] debug = "line-tables-only" to shrink linker work
  while keeping panics/backtraces useful.
- Add .cargo/config.toml selecting mold via gcc on x86_64-linux-gnu.
  Requires `apt install mold`. Other platforms use the default linker.
- cargo update: lockfile-only refresh of all minor/patch bumps within
  existing version constraints.

Cold debug build: ~1m 37s; touch-one-file rebuild: ~5s on Linux.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:36:42 -04:00
cameron 06fdcadf67 Merge pull request 'feature/canonical-date-taken' (#76) from feature/canonical-date-taken into master
Reviewed-on: #76
2026-05-06 21:15:57 +00:00
Cameron Cordes 9f1b3f6d9a date_taken_source: backfill 'exif' on legacy rows
Pre-resolver rows already had a populated `date_taken` from the old
kamadak-exif-only ingest path. The column-add migration left their
`date_taken_source` as NULL, and the drain's eligibility predicate
(`date_taken IS NULL OR date_taken_source = 'fs_time'`) skips them —
so they remain unlabelled forever and never benefit from the
resolver's exiftool fallback even if they're videos that should
upgrade.

Label them all `'exif'` in a one-shot UPDATE. Safe because every
write path that populated `date_taken` before the resolver landed was
a kamadak-exif read. Idempotent (the WHERE matches nothing on a
second run). Down.sql is a no-op — the labels stay correct under any
schema state, and the column-add migration is the right place to
revert if needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:05:00 -04:00
Cameron Cordes 7f12890f4b memories: single-SQL rewrite + 20-year lookback
Replaces the EXIF-loop + WalkDir-fallback pipeline that powered
`/memories` with a single per-library SQL query
(`get_memories_in_window`) that uses `strftime('%m-%d' | '%W' | '%m',
date_taken, 'unixepoch', tz_offset)` for calendar matching in the
client's timezone, plus a `years_back` lower bound and a
no-future-dates upper bound. Returns only the matching rows; the
handler applies per-library `PathExcluder` post-query and sorts.

Drops:
- `collect_exif_memories` — replaced by the single SQL query.
- `collect_filesystem_memories` — the canonical-date pipeline now
  populates `date_taken` for every row at ingest, so the WalkDir
  fallback that scanned 14k+ files each request is no longer needed.
- `get_memory_date_with_priority` and friends — request-time waterfall
  superseded by `date_resolver` running at ingest. The associated
  three priority-tests are dropped; their replacement lives in
  `date_resolver::tests`.

On a ~14k-file library this drops `/memories` from 10–15 s
(dominated by `fs::metadata` per row) to single-digit ms.

Bumps `DEFAULT_YEARS_BACK` from 15 → 20 to surface deeper archives
on matching anniversaries.

Note vs. ISO weeks: the original Rust used `chrono::iso_week().week()`
for week-span matching. SQLite's `%W` is Monday-anchored but uses week
0 for days before the first Monday, so it can disagree with ISO at
year boundaries by ±1. Acceptable for nostalgia browsing.

Adds 3 new DAO tests covering month-span filter, library scoping, and
the unknown-span-token guard. Also adds a CLAUDE.md section describing
the canonical-date pipeline end-to-end and the new
`DATE_BACKFILL_MAX_PER_TICK` env var.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:04:09 -04:00
Cameron Cordes 54e0635a98 date_backfill: per-tick drain for unresolved date_taken rows
Adds two ExifDao methods (`get_rows_needing_date_backfill` /
`backfill_date_taken`) and a `backfill_missing_date_taken` watcher pass
that runs on every tick alongside `backfill_unhashed_backlog`.

The drain queries the partial index for rows where `date_taken IS NULL`
or `date_taken_source = 'fs_time'`, batches up to
`DATE_BACKFILL_MAX_PER_TICK` paths (default 500), and feeds them through
`date_resolver::resolve_dates_batch` — a single exiftool subprocess
covers the whole tick. Rows that newly resolve to `exiftool` /
`filename` / `fs_time` get persisted via `backfill_date_taken` (touches
only `date_taken` + `date_taken_source` so EXIF / hash / perceptual
columns survive).

`filename`-sourced rows are intentionally not re-resolved — the regex
is authoritative when it matches and re-running exiftool wouldn't
change the answer. Files that have disappeared from disk are skipped
so a ghost row doesn't loop through the drain forever; the
missing-file scan in `library_maintenance` retires those separately.

Comes with two DAO unit tests (eligibility filter + column-isolation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:03:03 -04:00
Cameron Cordes 2d14291733 ingest: stamp canonical date_taken on every InsertImageExif
Wires `date_resolver::resolve_date_taken` into the three call sites
that build `InsertImageExif`:

- `process_new_files` (file watcher) — every newly-registered file gets
  the resolver's verdict so videos and EXIF-stripped images land with a
  real date instead of NULL.
- Upload handler — same waterfall on the post-multipart-write path.
- GPS-write handler — re-runs the waterfall after exiftool writes GPS
  and re-reads the EXIF, in case a previously fs_time-sourced row now
  has a real EXIF date to upgrade to.

This is a behavior change vs. the pre-rewrite `/memories` request-time
priority: EXIF now beats filename when both are present. A photo
named `Screenshot_2014-06-01.png` whose EXIF `DateTime` is 2021 now
appears under 2021. The reverse case (no EXIF, parseable filename) is
unchanged and continues to surface the filename date with
`date_taken_source = 'filename'`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:00:14 -04:00
Cameron Cordes 79e258eccd date_resolver: canonical date_taken waterfall with exiftool fallback
New module that consolidates the four-step ingest waterfall:
kamadak-exif (already in process via the caller's prior result) →
exiftool fallback → filename regex → earliest_fs_time. Each step is
tagged with a `DateSource` so the caller can persist provenance.

The exiftool fallback is what makes videos and MakerNote-hosted dates
land at all — kamadak-exif can't read QuickTime/MP4 or Nikon-style
sub-IFDs. Single-file mode shells out per call; batch mode pipes paths
on stdin via `-@ -` and fans the result through one subprocess so the
upcoming per-tick drain doesn't pay startup cost per row. The
`exiftool` PATH check is cached in a `OnceLock` to keep the drain
short-circuited on deploys without exiftool installed.

`SubSecDateTimeOriginal` and `ContentCreateDate` are pulled alongside
the standard tags to capture iPhone's sub-second precision and Apple's
preferred capture-time tag respectively. `FileModifyDate` is
deliberately *not* in the tag list — it's a filesystem-derived value
the resolver already covers via the `fs_time` step, and pulling it
through exiftool would mask "no real EXIF date" with a misleading
`source = exiftool` row.

Module is registered in both `lib.rs` and `main.rs` (sibling-module
pattern the rest of the bin uses); no callers wired in yet — that
lands in the next commit. Comes with 9 unit tests covering JSON
parsing edge cases, source-priority short-circuiting, and the
fs_time-when-no-exif path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:59:02 -04:00
Cameron Cordes 84326501a9 image_exif: add date_taken_source column
New nullable TEXT column tracks which step of the canonical-date
waterfall (kamadak-exif → exiftool → filename → fs_time) populated
`date_taken`. Lets a later per-tick drain re-resolve weak sources
(`fs_time`) once stronger ones become available, and gives the UI/debug
surface a way to answer "why does this photo show up under this date?".

Adds the column at all `InsertImageExif` construction sites with `None`
placeholders (the resolver wiring lands in a follow-up commit), and
extends the `update_exif` SET tuple so the column survives the GPS-write
re-read path. Partial index `idx_image_exif_date_backfill` is created
for the upcoming drain query.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:57:49 -04:00
cameron 5de9a322ac Merge pull request 'duplicates: folder-pair view of exact dups' (#75) from feature/folder-pair-duplicates into master
Reviewed-on: #75
2026-05-06 18:27:12 +00:00
Cameron Cordes 67cf0c7f73 duplicates: folder-pair view of exact dups
Bucket exact-dup rows by (library_id, dirname) pair on each side, then
filter by coverage = shared / min(folder_a_total, folder_b_total) and
an absolute floor on shared count. Surfaces "this folder is mostly
contained in that folder" matches that the per-file EXACT view buries
under one row each — e.g. an old phone-backup tree shadowing the
organized library, or a topic-grouped folder duplicating a date-grouped
one within the same library.

New endpoint: GET /duplicates/folder-pairs?library=&include_resolved=
&min_coverage=&min_shared=. Cached 5 min keyed on (library, include_resolved);
the user-tunable thresholds filter the cached unfiltered pair list so
slider drags don't re-bucket. Shares the resolve / unresolve flow with
the existing tabs — the frontend fans out N parallel /resolve calls,
one per shared content_hash.

Folder names carry no signal (BMW lives under Night Photos, not BMW_backup),
so bucketing is purely on (library_id, dirname) co-occurrence in
exact-dup groups. Within-folder dups (same hash twice in the same
folder) are skipped — those belong to the EXACT tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:43:29 -04:00
cameron 9ccb48233f Merge pull request 'exif: preserve filesystem mtime on GPS write' (#74) from fix/exif-preserve-mtime into master
Reviewed-on: #74
2026-05-04 20:12:08 +00:00
Cameron Cordes 1ddbca3413 exif: preserve filesystem mtime on GPS write
Pass -P to exiftool so write_gps doesn't bump the file's modification
time. For phone photos with no embedded EXIF datetime, the filesystem
mtime is often the only timestamp we have — losing it on every GPS
backfill would be data loss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:09:21 -04:00
cameron 82dd21b205 Merge pull request 'feature/duplicate-detection' (#73) from feature/duplicate-detection into master
Reviewed-on: #73
2026-05-03 22:34:49 +00:00
Cameron Cordes 57b7bad086 duplicates: library-aware visibility — only hide a demoted row when its survivor is reachable
Soft-marked rows used to disappear from /photos globally, including
from a library-scoped view that didn't contain the survivor at all.
A user browsing lib A who'd promoted a file from lib B as the
survivor would silently lose visibility on their own copy in lib A,
even though lib B's file isn't reachable from lib A's view.

Library-scoped queries now keep a demoted row visible when its
survivor lives in a library outside the current scope. Implemented
as a NOT EXISTS subquery against the same image_exif table aliased
as `survivor`. The unscoped (all-libraries) view is unchanged — every
survivor is reachable, so demoted rows stay hidden as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:24:07 -04:00
Cameron Cordes 98057c98a1 duplicates: tighten perceptual cluster — entropy band, asymmetric dHash, medoid prune
Three changes against "still too loose at lowest sensitivity":

- Popcount entropy band tightened from [8, 56] to [16, 48]. The wider
  band let too much low-frequency content through (skies, scans,
  faded film) where pHash collapses to near-uniform values that
  Hamming-trivially across hundreds of unrelated images.
- dHash check now uses an asymmetric stricter threshold
  (dhash_threshold = max(2, threshold/2)). pHash is the candidate-
  discovery signal; dHash is validation. Splitting the budget means
  a real near-dup survives both while incidental pHash collisions
  on uniform content get vetoed. Missing dHash on either side now
  rejects the edge (was: trust pHash alone).
- Single-link union-find can chain weakly-similar images via
  transitive edges. Added a medoid-validation pass: per cluster,
  pick the member with smallest summed distance to others, then
  drop any whose distance to it exceeds threshold. Two new tests
  pin both invariants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:19:48 -04:00
Cameron Cordes 7ca888e95d duplicates: filter low-entropy hashes + dHash double-check, fix backfill loop
The perceptual cluster was producing one giant first group that
contained hundreds of unrelated images. Two causes:
- Solid-colour images (skies, black frames, monochrome scans) all
  hash to near-zero pHashes that Hamming-distance-zero to each other.
- Single-link clustering on pHash alone is too permissive — a chain
  of weakly-similar images all collapses into one cluster.

Fixed by skipping hashes outside the popcount [8, 56] band (uniform
content) and requiring dHash agreement within threshold before
unioning a candidate edge from the BK-tree. Two new tests pin both
invariants.

Backfill bin separately fix: decode-failed rows kept phash_64=NULL
and got re-pulled by every batch, infinite-looping on a queue of
unbreakable formats. Persist a 0/0 sentinel on decode failure so
the row leaves the candidate set; the all-zero hash is excluded
from clustering by the same entropy filter so it doesn't pollute
results.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:08:05 -04:00
Cameron Cordes 7584cd8792 duplicates: perceptual hash + soft-mark resolution + upload 409
Adds pHash + dHash columns alongside the existing blake3 content_hash so
near-duplicates (re-encoded, resized, format-converted copies) become
queryable. /duplicates/{exact,perceptual} return groups; /duplicates/
{resolve,unresolve} flip a duplicate_of_hash soft-mark on losing rows
and union perceptual-only tag sets onto the survivor. The default
/photos listing filters duplicate_of_hash IS NULL so demoted siblings
stop cluttering the grid; include_duplicates=true opts back in for
Apollo's review modal. Upload now hashes bytes pre-write and returns
409 with the canonical sibling when a file's bytes already exist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:36:01 -04:00
cameron 4340b164eb Merge pull request 'perf/faces-embeddings-no-clone' (#72) from perf/faces-embeddings-no-clone into master
Reviewed-on: #72
2026-05-01 23:09:22 +00:00
Cameron Cordes fb4df4b195 style: cargo fmt sweep
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:01:00 -04:00
Cameron Cordes 1d9b9a0bc4 faces: avoid 40 MB row clone in /faces/embeddings
list_embeddings cloned the full FaceDetectionRow inside the filter_map
just to pair it with the base64-encoded embedding. The 2 KB BLOB was
already on the row — at 20k unassigned faces that's 40 MB of pointless
heap traffic per Apollo cluster-suggest run. Move the bytes out via
Option::take() so the row drops the BLOB instead of duplicating it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:00:55 -04:00
cameron 7998a0c9b0 Merge pull request 'feature/per-library-excluded-dirs' (#71) from feature/per-library-excluded-dirs into master
Reviewed-on: #71
2026-05-01 20:11:10 +00:00
Cameron Cordes 58f010f302 docs(claude): pin excluded_dirs entry-form syntax
The two entry shapes for libraries.excluded_dirs / EXCLUDED_DIRS
are not symmetric:
  - /sub/path → multi-segment, library-root-anchored, recursive
  - name     → single component anywhere in the tree

Without this pinned, a reasonable read of the column doc would be
"any path-like string works" — but a multi-segment string without a
leading slash silently never matches (the no-slash form scans path
components for exact string equality, and components are
slash-free).

No code change; just documentation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:05:58 +00:00
Cameron Cordes 814066551e multi-library: per-library excluded_dirs
Adds a nullable comma-separated TEXT column to the libraries table.
Effective excludes for a walk = (env-var globals) ∪
(library.excluded_dirs). Empty / NULL = no library-specific
extras; the global env var still applies.

Migration (2026-05-01-110000_libraries_excluded_dirs)

  ALTER TABLE libraries ADD COLUMN excluded_dirs TEXT. NULL on every
  existing row — no behavior change on upgrade.

Library struct + helpers (libraries.rs)

  - Library gains excluded_dirs: Vec<String>, parsed from the column
    by parse_excluded_dirs_column (drops empties / whitespace,
    matches the env-var parser).
  - Library::effective_excluded_dirs(globals) returns the union.
  - From<LibraryRow> hydrates the field on AppState construction so
    /libraries surfaces it.

Watcher / walkers / memories

  Every per-library walker now consults the effective set:
    - process_new_files (file-watch ingest, RAW/EXIF/face)
    - process_face_backlog (filter_excluded inherits)
    - create_thumbnails (startup + new-file branch)
    - update_media_counts (Prometheus gauge)
    - cleanup_orphaned_playlists (per-library source-existence check)
    - memories endpoint (PathExcluder)

  Effective set is computed once per per-library iteration in the
  watcher tick and threaded through; called functions retain their
  flat &[String] signature (no per-library awareness needed inside
  the walker primitives).

Use case: mount a parent directory while a sibling library covers
a child subtree, and exclude the child subtree from the parent so
the libraries don't double-walk / double-write image_exif. With
hash-keyed derived data (Branches B/C), the duplication-avoidance
is the only cost prevented — face / tag / insight sharing was
already correct via content_hash.

Tests: 228 pass (226 from previous + 2 new in libraries::tests:
parse_excluded_dirs_column edge cases,
effective_excluded_dirs_unions_global_and_per_library).

CLAUDE.md gains a "Per-library excludes" subsection of the
multi-library data model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:54:17 +00:00
cameron 4f17af688e Merge pull request 'multi-library: operator kill switch via libraries.enabled' (#70) from feature/library-enabled-flag into master
Reviewed-on: #70
2026-05-01 19:15:20 +00:00
Cameron Cordes 3598bb2cfe multi-library: operator kill switch via libraries.enabled
A small follow-up to Branches A/B/C. Adds a nullable-default-1
boolean column to the `libraries` table that controls whether the
watcher considers the library at all. Useful for staging a new
mount before committing to ingest, and as a maintenance kill
switch when a library needs to be quiet without being unmounted.

Migration (2026-05-01-100000_libraries_enabled_flag)

  ALTER TABLE libraries ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT 1.
  Existing rows stay enabled — no behavior change on upgrade.

Watcher gate (main.rs)

  At the top of the per-library loop, if !lib.enabled { continue; }
  — runs BEFORE the availability probe. Disabled libraries don't
  enter the health map, don't get probed, don't get ingest, don't
  get any maintenance pass. The initial sweep before the loop's
  first sleep also skips disabled libraries.

Orphan-GC consensus (library_maintenance.rs)

  all_libraries_online filters disabled libraries out of the
  consensus check — they're treated as out-of-scope, not as
  blockers. Otherwise flipping enabled=false would permanently
  halt orphan GC for the rest of the system, which is the opposite
  of the intended kill-switch semantics.

Cross-library duplicates: safe by construction. Hash-keyed derived
data (face_detections, tagged_photo with hash, photo_insights with
hash) is anchored by ANY image_exif row carrying the hash. Disabling
a library does NOT delete its image_exif rows, so a hash referenced
by a disabled library's row stays anchored — derived data survives.
collect_orphan_hashes deliberately doesn't filter image_exif by
library.enabled for exactly this reason.

No HTTP endpoint. Library mutation is rare-enough infra work that a
SQL toggle is fine, and a public mutation endpoint without a role /
permission story would be poorly-prioritized exposure for a
single-user tool. Documented in CLAUDE.md.

Tests: 226 pass (225 from Branch C + 1 new
all_libraries_online_treats_disabled_as_out_of_scope, which proves
that even an explicit Stale entry on a disabled library doesn't
block the consensus).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:10:24 +00:00
cameron 23448cf5e6 Merge pull request 'feature/library-handoff-and-gc' (#69) from feature/library-handoff-and-gc into master
Reviewed-on: #69
2026-05-01 18:27:40 +00:00
Cameron Cordes d809ddee44 library_maintenance: clarify orphan-gc log wording
"marked 2 new" parses as "2 new files" on first read — but the
unit is content_hashes, and the action is observing them as
orphaned (becoming-deleted, not appearing). Reword:

  "{} new orphan hash(es) marked, {} revived"

instead of "marked {} new, revived {}". Also pluralize the deleted
counts ("row(s)") and append the pending-set size to the success
log so a tick that both deletes and re-marks doesn't lose the
trailing-state context.

No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:01:01 +00:00
Cameron Cordes fa98d147be library_maintenance: log orphan-gc decisions in stale-library path too
run_orphan_gc returned early on the !all_online branch before the
final debug/info log line, so the GC was effectively invisible
whenever any library was Stale — exactly the dry-run scenario where
operators most want to confirm the safety gate is firing. Add the
same conditional log inside the early-return branch (plus a
"deferred — at least one library Stale" hint in the info-level
variant when there's something newly marked).

No behavior change beyond observability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:14:09 +00:00
Cameron Cordes 5f247be1f1 docs(claude): note in-place edit gap as future Branch D
The maintenance pipeline added in Branch C assumes (library_id,
rel_path) bytes are stable for as long as the file lives at that
path. In-place edits (crop, re-export to same name) bypass
process_new_files's already-indexed check, so the row's
content_hash stays pinned to the original bytes — tags / faces /
insights remain attached to that hash silently.

Document the gap and the proposed shape of the fix:
  - Stale-content detection pass: compare last_modified / size_bytes
    to fs::metadata, re-hash on mismatch, update image_exif.
  - "Content branched" semantics on hash change: faces re-run, tags
    migrate forward (user intent survives a crop), insights migrate
    + flag for re-generation, favorites follow path.
  - Apollo derived.db cache invalidation belongs in the same design
    cycle, not after.

Captured here so the design intent is clear before someone hits the
case in real life. No code change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:53:08 +00:00
Cameron Cordes 263e27e108 multi-library: handoff + orphan GC with two-tick consensus
Branch C of the multi-library data-model rollout. Implements the
operational maintenance pipeline pinned in CLAUDE.md → "Multi-library
data model" / "Library availability and safety". Branches A and B
land first; this branch builds on top.

New module: src/library_maintenance.rs

Three idempotent passes the watcher runs every tick after the
per-library ingest loop:

1. Missing-file scan (per online library)

   For each Online library, load a paginated page of image_exif rows
   (IMAGE_EXIF_MISSING_SCAN_PAGE_SIZE, default 500), stat() each one,
   and delete rows whose source file is NotFound. Permission/IO
   errors are skipped, never deleted. Capped at
   IMAGE_EXIF_MISSING_DELETE_CAP_PER_TICK (default 200) per library
   per tick — so a pathological mount that returns NotFound for
   everything can't wipe the table in one cycle. Cursor advances
   across ticks, wraps on partial-page returns, and naturally cycles
   through the entire library over many minutes. Skipped wholesale
   for Stale libraries via the existing probe gate.

2. Back-ref refresh (DB-only)

   For face_detections / tagged_photo / photo_insights: any
   hash-keyed row whose (library_id, rel_path) no longer matches an
   image_exif row, but whose content_hash does, is repointed at a
   surviving image_exif location. Pure SQL with EXISTS guards so
   rows whose hash is fully orphaned are left alone (the orphan GC
   handles those). Idempotent; no availability gate needed.

   This is what makes a recent → archive move invisible to readers:
   when pass 1 retires the lib-A row, pass 2 pivots tags / faces /
   insights to lib-B's surviving path before any client notices.

3. Orphan GC (destructive)

   Hash-keyed derived rows whose content_hash has no image_exif
   referent are GC-eligible. Two-tick consensus: a hash must be
   observed orphaned on two consecutive ticks AND every library must
   be Online for both. A single Stale tick within the window cancels
   all pending deletes (they remain marked but won't be promoted) —
   they're re-evaluated next tick. The pending set lives in
   OrphanGcState (in-memory); a watcher restart resets it, which can
   only delay a delete, never cause one. Hashes that re-appear in
   image_exif between ticks are "revived" from the pending set
   (handles transient share unmount / remount).

Two new ExifDao methods:
  - list_rel_paths_for_library_page(library_id, limit, offset) for
    the paginated missing-file scan.
  - (count_for_library landed in Branch A.)

Watcher wiring (main.rs)

Per-library: missing-file scan inside the existing per-library
loop, after process_new_files, gated by the same probe check that
already protects ingest. After the loop: reconcile (Branch B),
back-ref refresh, then run_orphan_gc. The maintenance connection is
opened once per tick (image_api::database::connect), used by all
three DB-only passes, and dropped at end of tick.

CLAUDE.md gains a "Maintenance pipeline" subsection that describes
the three passes and their interaction with the existing
availability-and-safety policy.

Tests: 225 pass (217 from Branch B + 8 new in library_maintenance
covering back-ref refresh including the fully-orphaned no-op case,
two-tick GC consensus, Stale-tick consensus reset, image_exif
re-appearance revival, multi-table delete, and the
all_libraries_online helper).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:27:53 +00:00
cameron a0283a6362 Merge pull request 'multi-library: hash-keyed tagged_photo + photo_insights with reconciliation' (#68) from feature/hash-keyed-derived-data into master
Reviewed-on: #68
2026-05-01 16:16:38 +00:00
Cameron Cordes 48cac8c285 multi-library: hash-keyed tagged_photo + photo_insights with reconciliation
Branch B of the multi-library data-model rollout. tagged_photo and
photo_insights now follow the bytes (content_hash), not the path,
matching the policy pinned in CLAUDE.md "Multi-library data model".
Branch A's availability probe and EXIF scoping land first; this
branch builds on top.

Migration (2026-05-01-000000_hash_keyed_derived_data)

  Adds nullable content_hash columns to tagged_photo and photo_insights,
  with partial indexes on the non-null subset to keep the index small
  during the transitional window. The migration backfills from
  image_exif:
    * tagged_photo joins on rel_path alone (no library_id available);
    * photo_insights joins on (library_id, rel_path), unambiguous.
  Rows whose image_exif hash isn't known yet stay null and the runtime
  reconciliation pass populates them as the hash backlog drains.

Insert-time population

  TagDao::tag_file looks up image_exif.content_hash by rel_path before
  inserting; the hash is written into the new column.
  InsightDao::store_insight does the same scoped to (library_id,
  rel_path). Caller-supplied hash on InsertPhotoInsight wins; otherwise
  the DAO does the lookup. Both paths fall back to None if the hash
  isn't known yet — reconciliation backfills.

Reconciliation (database/reconcile.rs)

  Three idempotent passes the watcher runs once per tick after the
  per-library backfill loop:
    1. tagged_photo NULL hashes → populate from image_exif by rel_path.
    2. photo_insights NULL hashes → populate by (library_id, rel_path).
    3. photo_insights scalar merge — when multiple is_current rows
       share a content_hash, keep the earliest generated_at as
       current; demote the rest. Demoted rows keep their data so
       /insights/history is unaffected; only the "current" pointer
       narrows to one per hash.

  No filesystem dependency, so reconcile doesn't need the availability
  gate; runs every tick. Logs once when something changed, debug
  otherwise.

  Tags are set-valued under the policy (union on read, already
  DISTINCT in queries), so there is no analogous tag-collapse pass —
  duplicate (tag_id, content_hash) rows across libraries are
  harmless.

Read paths are unchanged in this branch — lookup_tags_batch's
existing rel_path-via-hash-sibling expansion still produces the
correct merge. A follow-up can simplify reads to use the new column
directly for performance.

Tests: 217 pass (212 pre-existing + 5 new in reconcile covering
NULL-fill, hash-not-yet-known no-op, library scoping on insights,
earliest-wins collapse, idempotency).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:52:16 +00:00
cameron cce8f0c1b7 Merge pull request 'feature/multi-library-data-model' (#67) from feature/multi-library-data-model into master
Reviewed-on: #67
2026-05-01 14:40:16 +00:00
Cameron Cordes 48ed7be5d9 libraries: initial availability sweep before watcher's first sleep
new_health_map seeds every library as Online, and the watcher's tick
loop sleeps WATCH_QUICK_INTERVAL_SECONDS (default 60s) before its
first probe — meaning /libraries reported the optimistic default for
up to a minute after boot, even when a share was clearly unmounted.

Run the same refresh_health pass once at the top of the watcher
thread before entering the sleep loop. /libraries is then truthful
within milliseconds of the watcher thread starting (effectively from
the first HTTP request, since the watcher spawns well before the
server binds).

The per-tick gate inside the loop is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:33:45 +00:00
Cameron Cordes eea1bf3181 multi-library: availability probe + scoped EXIF queries + collision fixes
Branch A of the multi-library data-model rollout. Three threads of
correctness/safety work that ship together because the new mount
needs all three before it can land:

1. Library availability probe (libraries.rs, state.rs, main.rs)

   New LibraryHealth (Online | Stale { reason, since }) and a shared
   LibraryHealthMap on AppState. Probe checks root_path exists +
   is_dir + readable + non-empty (relative to a "had_data" signal so
   fresh mounts aren't downgraded). The watcher tick begins with a
   refresh_health() per library; stale libraries skip ingest, the
   hash backfill, and face-detection backlog drains for that tick.
   The orphaned-playlist cleanup also gates on every library being
   online — a missing source on a stale library is indistinguishable
   from a transient unmount, and the cleanup is destructive.

   /libraries now returns each library with its current health
   state. Logs only on Online↔Stale transitions so a long outage
   doesn't spam.

   New ExifDao::count_for_library is the "had_data" signal.

2. EXIF queries scoped by library_id (database/mod.rs, files.rs,
   main.rs, tags.rs)

   query_by_exif gains an Option<i32> library filter; /photos and
   /photos/exif now pass it. Without this, an EXIF-filtered request
   scoped to ?library=N returned cross-library results because the
   handler resolved the library but didn't push it through to SQL.

   get_exif_batch gains the same option. The watcher's per-library
   ingest, face-candidate build, and content-hash backfill all
   scope to their library; the union-mode /photos date-sort path
   and the library-agnostic tag fan-out (lookup_tags_batch, by
   design) keep using None.

3. Derivative-path collision fixes (content_hash.rs, main.rs)

   New content_hash::library_scoped_legacy_path helper:
   <derivative_dir>/<library_id>/<rel_path>. Thumbnail generation
   (startup walk + watcher needs-thumb check) and serving now use
   it; serving falls back to the bare-legacy mirrored path so
   pre-multi-library deployments keep working without
   regeneration. Without this, lib2 with the same rel_path as lib1
   would have its thumbnail request short-circuit to lib1's image.

   Orphaned-playlist cleanup walks every library when checking for
   the source video (was: BASE_PATH only). Without this, mounting
   a 2nd library and waiting 24h would delete every playlist whose
   source lived only in the 2nd library.

   The HLS playlist write path collision (filename-only basename,
   not rel_path) is left as a known issue with a TODO at the call
   site — the actor-pipeline rewrite belongs in Branch B/C.

Tests: 212 pass (cargo test --lib). New tests cover the probe
states (online / missing root / non-dir / empty-with-prior-data),
refresh_health transitions, query_by_exif scoping, get_exif_batch
keying on (library_id, rel_path), library_scoped_legacy_path, and
count_for_library.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:12:49 +00:00
Cameron Cordes 2f91891459 docs(claude): pin multi-library data model + availability/safety policy
Adds a "Multi-library data model" section that classifies each table as
intrinsic-to-bytes (hash-keyed), user-intent-about-a-photo (hash-keyed),
or library-administrative ((library_id, rel_path)). Spells out merge
semantics on read (union for set-valued, earliest-wins for scalar),
write attribution (binds to bytes, not to current library), the
transitional-state rules for hash-less rows, library handoff behavior
on archive moves, and orphan GC.

Adds a "Library availability and safety" subsection: every watcher
tick begins with a presence probe; destructive paths (move-handoff
re-keying, orphan GC) require both/all libraries online and
confirmed-clean for two consecutive ticks. A NAS reboot, USB pull, or
VPN drop must never trigger destruction — the worst case is that
derived-data work pauses until the share returns.

The face_detections table is referenced as the existing reference
implementation of the policy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:11:42 +00:00
cameron 3d162105f7 Merge pull request 'feature/edit-tag' (#66) from feature/edit-tag into master
Reviewed-on: #66
2026-05-01 01:03:40 +00:00
Cameron 98601973f7 faces: log at the three 503 paths in update_face_handler
PATCH /image/faces/{id} can return 503 from three places (face client
disabled, transient embed error, mid-flight disable) and none of them
were logging — operator sees the status code but nothing in the Rust
log explaining why. Add warn! lines at each so future bbox-edit
failures aren't silent. Response body is unchanged so existing clients
keep working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 20:57:51 -04:00
Cameron 862917b0d1 gitignore: SQLite WAL runtime + local docs/specs dirs
*.db-shm / *.db-wal show up in the working tree whenever the server
runs (the WAL/journal pragmas in connect()), and /docs and /specs
hold per-feature design notes that stay local per the project's
"spec docs not in git" convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 20:31:19 -04:00
Cameron 44d677528e tags: add edit + delete endpoints, enable FK enforcement
PUT /image/tags/{id} renames a tag globally; DELETE /image/tags/{id}
removes a tag and every photo's reference. Rename returns 200/404/409
(case-insensitive name conflict) / 400 (empty name); delete returns
204/404. New migration adds a UNIQUE COLLATE NOCASE index on
tags.name with a pre-flight pass that collapses existing case-
insensitive duplicates onto the lowest id.

The connection setup now sets PRAGMA foreign_keys = ON. The schema
already declares ON DELETE CASCADE / SET NULL on several tables —
those clauses were documentation-only because SQLite has FK
enforcement off per-connection by default. Audited every
diesel::delete site; each touches either no inbound FKs or has a
matching policy. delete_tag relies on the tagged_photo cascade
instead of doing manual cleanup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 20:26:35 -04:00
cameron 89b743ba54 Merge pull request 'faces: count distinct content_hash in stats total_photos' (#65) from face-stats-dedup-hash into master
Reviewed-on: #65
2026-04-30 22:43:58 +00:00
Cameron Cordes 323097c650 faces: count distinct content_hash in stats total_photos
face_detections is keyed on content_hash (one row per unique bytes,
shared across libraries / duplicate paths) but total_photos was
COUNT(*) over image_exif rows. A file present at multiple rel_paths or
across libraries inflated the denominator without inflating the
numerator, leaving a permanent gap (e.g. 1101/1103 with nothing
actually pending detection).

Switch total_photos to COUNT(DISTINCT content_hash) so numerator and
denominator live in the same domain. Exclude rows with NULL
content_hash from the count — they're held in the hash-backfill
backlog, not the detection backlog, and counting them pins the bar
below 100% for the duration of that pass.

CLAUDE.md: document the stats domain rule next to the rest of the
face-detection notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 22:41:20 +00:00
cameron d0833177c7 Merge pull request 'feature/face-stats-exclude-videos' (#64) from feature/face-stats-exclude-videos into master
Reviewed-on: #64
2026-04-30 21:17:19 +00:00
Cameron Cordes 67abd8d8ff style: cargo fmt
Pre-existing whitespace drift in test bodies, normalized by rustfmt.
No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 21:16:34 +00:00
Cameron Cordes 0840d55c70 faces: exclude videos from backlog drain and SCANNED denominator
list_unscanned_candidates pulled every hashed image_exif row, including
videos. filter_excluded then dropped them client-side without writing a
marker, so the same set re-appeared every watcher tick — emitting the
"backlog drain — running detection on N candidate(s)" log forever and
producing no progress.

face_stats.total_photos counted the same video rows in the denominator,
so the SCANNED percentage was structurally capped below 100%.

Add an image-extension SQL predicate (case-insensitive, sourced from
file_types::IMAGE_EXTENSIONS) and apply it to both queries. Videos
never enter the candidate set, total_photos counts only what can
actually be scanned, and 100% becomes reachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 21:16:30 +00:00
cameron dbb046dfa8 Merge pull request 'indexer: prune EXCLUDED_DIRS at WalkDir time, extract enumerate_indexable_files' (#63) from feature/exclude-dirs-at-index-time into master
Reviewed-on: #63
2026-04-30 20:24:18 +00:00
137 changed files with 38443 additions and 6221 deletions
+3
View File
@@ -0,0 +1,3 @@
[target.x86_64-unknown-linux-gnu]
linker = "/usr/bin/gcc"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
+96 -2
View File
@@ -53,11 +53,60 @@ AGENTIC_CHAT_MAX_ITERATIONS=6
# OPENROUTER_HTTP_REFERER=https://your-site.example # OPENROUTER_HTTP_REFERER=https://your-site.example
# OPENROUTER_APP_TITLE=ImageApi # OPENROUTER_APP_TITLE=ImageApi
# ── AI Insights — local backend switch ──────────────────────────────────
# Picks which local LLM stack the server uses for chat, vision describe,
# and embeddings. `ollama` (default) uses the OLLAMA_* settings above;
# `llamacpp` uses the LLAMA_SWAP_* settings below. The switch is global
# and applies to both `backend=local` and `backend=hybrid` (hybrid keeps
# chat on OpenRouter but still uses this stack for the describe pass).
# Don't flip mid-deploy without re-embedding existing index rows —
# mixed vector spaces break similarity search.
# LLM_BACKEND=ollama
# ── AI Insights — llama.cpp / llama-swap (optional) ─────────────────────
# Set LLAMA_SWAP_URL plus LLM_BACKEND=llamacpp to swap the local stack
# off Ollama. Talks OpenAI-compatible /v1 to a llama-swap proxy fronting
# per-slot llama-server instances. Chat models receive images directly
# via content-parts (vision-capable models assumed); a separate vision
# slot is used only by the describe_photo tool and describe-image utility.
# LLAMA_SWAP_URL=http://localhost:9292/v1
# LLAMA_SWAP_PRIMARY_MODEL=chat
# Optional dedicated vision slot for describe_image. Defaults to
# PRIMARY_MODEL so describe_photo works without extra config.
# LLAMA_SWAP_VISION_MODEL=vision
# LLAMA_SWAP_EMBEDDING_MODEL=embed
# Comma-separated allowlist surfaced by /insights/models when
# LLM_BACKEND=llamacpp. All report has_vision=true.
# LLAMA_SWAP_ALLOWED_MODELS=chat,vision,embed
# LLAMA_SWAP_REQUEST_TIMEOUT_SECONDS=180
# ── Unified search translation model (optional) ─────────────────────────
# /photos/search/unified runs one small LLM call to translate a natural-
# language query into structured filters + a semantic term, then CLIP-ranks.
# That step needs an LLM AND CLIP available at once. On a tight VRAM budget a
# large chat model can't co-reside with CLIP, so pin a small, fast model here
# (it can stay loaded alongside CLIP and the chat model). Precedence:
# UNIFIED_SEARCH_MODEL > the client's selected model > the configured default.
# Use the configured backend (LLM_BACKEND); local only — no hybrid.
# UNIFIED_SEARCH_MODEL=qwen3-0.6b
# ── Text-to-speech (optional, requires LLAMA_SWAP_URL) ───────────────────
# TTS routes through the same llama-swap proxy (a Chatterbox model id), so it
# only needs LLAMA_SWAP_URL — it does NOT require LLM_BACKEND=llamacpp.
# Powers POST /tts/speech and the /tts/voices* endpoints (read-aloud insights
# + voice cloning in the mobile app).
# LLAMA_SWAP_TTS_MODEL=chatterbox # TTS model id in config.yaml
# LLAMA_SWAP_TTS_VOICE=m # default voice when a request omits one
# LLAMA_SWAP_TTS_REF_SECONDS=30 # max voice-clone reference clip length (s)
# LLAMA_SWAP_TTS_REQUEST_TIMEOUT_SECONDS=600 # synth timeout (long chunked text)
# ── AI Insights — sibling services (optional) ─────────────────────────── # ── AI Insights — sibling services (optional) ───────────────────────────
# Apollo (places + face inference). Single Apollo deploys typically set # Apollo (places, face inference, CLIP encoders). Single-Apollo deploys
# only APOLLO_API_BASE_URL and let the face client fall back to it. # typically set only APOLLO_API_BASE_URL and let the face + CLIP
# clients fall back to it.
# APOLLO_API_BASE_URL=http://apollo.lan:8000 # APOLLO_API_BASE_URL=http://apollo.lan:8000
# APOLLO_FACE_API_BASE_URL=http://apollo.lan:8000 # APOLLO_FACE_API_BASE_URL=http://apollo.lan:8000
# APOLLO_CLIP_API_BASE_URL=http://apollo.lan:8000
# SMS_API_URL=http://localhost:8000 # SMS_API_URL=http://localhost:8000
# SMS_API_TOKEN= # SMS_API_TOKEN=
@@ -80,6 +129,51 @@ FACE_DETECT_TIMEOUT_SEC=60
FACE_BACKLOG_MAX_PER_TICK=64 FACE_BACKLOG_MAX_PER_TICK=64
FACE_HASH_BACKFILL_MAX_PER_TICK=2000 FACE_HASH_BACKFILL_MAX_PER_TICK=2000
# ── CLIP semantic photo search ──────────────────────────────────────────
# ImageApi calls Apollo's /api/internal/clip/{encode_image,encode_text}
# to populate per-photo embeddings during the watcher's backlog drain
# and to encode user queries at /photos/search time. Disabled when
# neither APOLLO_CLIP_API_BASE_URL nor APOLLO_API_BASE_URL is set.
#
# Per-watcher-tick cap on the encode drain. Default 32 ≈ ~1 photo/sec
# on CPU, ~30 photos/sec on a single-GPU host (Apollo's threadpool
# is 1 on CUDA, so concurrency is bounded server-side regardless of
# our setting). Bump on a fresh deploy to clear the backlog faster.
CLIP_BACKLOG_MAX_PER_TICK=32
# Client-side parallel encode calls per drain pass. Apollo's GPU pool
# serializes server-side; this just overlaps file-IO with inference.
CLIP_ENCODE_CONCURRENCY=4
# Per-encode HTTP timeout. CPU-only Apollo deploys may need higher.
CLIP_REQUEST_TIMEOUT_SEC=60
# ── RAG / search ──────────────────────────────────────────────────────── # ── RAG / search ────────────────────────────────────────────────────────
# Set to `1` to enable cross-encoder reranking on /search results. # Set to `1` to enable cross-encoder reranking on /search results.
SEARCH_RAG_RERANK=0 SEARCH_RAG_RERANK=0
# ── Nightly reel pre-generation (Phase 3+) ──────────────────────────────
# Set to `1` to enable the scheduler. Disabled by default.
# REEL_PREGEN_ENABLED=1
# Hour (0-23) when the nightly batch fires. Default 3 AM.
# REEL_PREGEN_HOUR=3
# Day of week for weekly reels (0=Sun, 1=Mon, …). Default Monday.
# REEL_PREGEN_WEEK_DOW=1
# Timezone offset in minutes from UTC (e.g., -480 = PST). Defaults to
# the server's local timezone.
# REEL_PREGEN_TZ_OFFSET_MINUTES=
# Fixed timezone offset — overrides auto-detect to avoid DST shifts.
# When set, both the DB fallback and env fallback use this value.
# REEL_PREGEN_TZ_FIXED_MINUTES=-480
# Voice ID for narration (e.g., "grandma"). Falls back to the value
# stored in the user_ai_prefs DB row when set.
# REEL_PREGEN_VOICE=
# Library filter: a library id (e.g. "1") or "all" for every library.
# REEL_PREGEN_LIBRARY=all
# Max agentic tool iterations for pre-gen scripter. Default 8.
# REEL_PREGEN_MAX_TOOL_ITERS=8
#
# On-disk reel cache sweep (runs every 24h, independent of pre-gen). Removes
# reel MP4s with no ledger row + no live job that are older than the max age —
# i.e. the on-demand cache, which otherwise grows forever. Set to 0 to disable.
# REEL_CACHE_SWEEP_ENABLED=1
# Age (days) before an unreferenced reel MP4 is swept. Default 7.
# REEL_CACHE_MAX_AGE_DAYS=7
+9
View File
@@ -0,0 +1,9 @@
# Normalize line endings in the repo to LF. Windows checkouts can still
# present working-copy files as CRLF; this just keeps the committed history
# stable so contributors on any OS don't see whitespace-only diffs every
# time someone touches a file.
* text=auto eol=lf
# Migrations and SQL must be LF — SQLite parsers don't care, but diffing
# is much cleaner with stable endings.
*.sql text eol=lf
+6
View File
@@ -2,8 +2,14 @@
database/target database/target
*.db *.db
*.db.bak *.db.bak
*.db-shm
*.db-wal
.env .env
# Server-local TTS pronunciation overrides (tts_pronunciations.example.json is the template)
/tts_pronunciations.json
/tmp /tmp
/docs
/specs
# Default ignored files # Default ignored files
.idea/shelf/ .idea/shelf/
+418 -10
View File
@@ -76,7 +76,10 @@ cargo run --bin cleanup_files -- --base-path /path/to/media --database-url ./dat
### Core Components ### Core Components
**Layered Architecture:** **Layered Architecture:**
- **HTTP Layer** (`main.rs`): Route handlers for images, videos, metadata, tags, favorites, memories - **Startup wiring** (`main.rs`): only ~350 lines — env load, migrations, AppState, route registration, server bind. Background jobs are kicked off here but defined elsewhere.
- **HTTP Layer** (`handlers/{image,video,favorites}.rs`, `files.rs`, `tags.rs`, `faces.rs`, `memories.rs`, `ai/handlers.rs`): the route handlers, grouped by domain.
- **Background loops** (`watcher.rs`): the file-watcher tick (`watch_files`, `process_new_files`) and the orphaned-playlist cleanup (`cleanup_orphaned_playlists`). Per-tick drains are factored into `backfill.rs` (`backfill_unhashed_backlog`, `backfill_missing_date_taken`, `backfill_missing_content_hashes`, `process_face_backlog`, `build_face_candidates`).
- **Thumbnails** (`thumbnails.rs`): generation pipeline + the `IMAGE_GAUGE` / `VIDEO_GAUGE` Prometheus metrics.
- **Auth Layer** (`auth.rs`): JWT token validation, Claims extraction via FromRequest trait - **Auth Layer** (`auth.rs`): JWT token validation, Claims extraction via FromRequest trait
- **Service Layer** (`files.rs`, `exif.rs`, `memories.rs`): Business logic for file operations and EXIF extraction - **Service Layer** (`files.rs`, `exif.rs`, `memories.rs`): Business logic for file operations and EXIF extraction
- **DAO Layer** (`database/mod.rs`): Trait-based data access (ExifDao, UserDao, FavoriteDao, TagDao) - **DAO Layer** (`database/mod.rs`): Trait-based data access (ExifDao, UserDao, FavoriteDao, TagDao)
@@ -104,6 +107,242 @@ All database access goes through trait-based DAOs (e.g., `ExifDao`, `SqliteExifD
- `query_by_exif()`: Complex filtering by camera, GPS bounds, date ranges - `query_by_exif()`: Complex filtering by camera, GPS bounds, date ranges
- Batch operations minimize DB hits during file watching - Batch operations minimize DB hits during file watching
### Multi-library data model
ImageApi supports more than one library (a library = a `(name, root_path)`
row in the `libraries` table that maps to a mounted directory tree). The
same bytes may exist under more than one library — typical case is an
"active" library plus an "archive" library that ingests files as they age
out — and the data model is designed so that derived data follows the
**bytes**, not the path, while user-managed data does the same.
**The principle.** A photo's identity is its `content_hash` (blake3, see
`src/content_hash.rs`). Anything we compute from or attach to a photo is
keyed on that hash so it survives:
- the same file appearing in a second library (backup / archive / mirror),
- the file moving between libraries (recent → archive handoff),
- the file moving within a library (re-organized rel_path),
- intra-library duplicates (same bytes at two paths).
**Table classification.** Three categories drive the keying decision:
| Category | Key | Rationale | Tables |
|---|---|---|---|
| Intrinsic to bytes | `content_hash` | Rerunning is wasted work (or LLM cost) | `face_detections` ✓, `image_exif` (target), `photo_insights` (target), `video_preview_clips` (target) |
| User intent about a photo | `content_hash` | "Tag this photo" means the bytes, not a path | `tagged_photo` (target), `favorites` (target) |
| Library administrative | `(library_id, rel_path)` | Tied to a specific filesystem location | `libraries`, `entity_photo_links`, the `rel_path` back-ref columns on hash-keyed tables |
✓ = already implemented this way. *(target)* = today still keyed on
`(library_id, rel_path)` and slated for migration. The migration adds a
nullable `content_hash` column, populates it from `image_exif` where
known, and read paths fall back to rel_path while the hash is null.
**Carrying a `rel_path` even when hash-keyed.** Hash-keyed tables retain
`(library_id, rel_path)` columns as a denormalized **back-reference**, not
as the key. This lets a single query answer "what is at this path right
now" without joining through `image_exif`, and supports the path-only
endpoints that predate the hash. `face_detections` is the reference
implementation: hash is the truth, path is a hint.
**Merge semantics on read.** When the same hash has rows under more than
one library:
- Set-valued data (tags, favorites, faces, entity links) → **union**.
- Scalar data (current insight, EXIF row, video preview clip) → earliest
`generated_at` / `created_time` wins. The historical lib1 row beats a
re-generated lib2 row, so the user's curated insight isn't shadowed by
a re-run on archive ingest.
**Write attribution.** A new tag/favorite/insight created while viewing
under lib2 binds to the bytes, not to lib2 — so it shows up under lib1
too. This is by design, but it's the most surprising rule on first
encounter; clients should not assume tags are library-scoped.
**Hash-less rows (transitional state).** During and immediately after a
new mount, `image_exif.content_hash` is being populated by
`backfill_unhashed_backlog` (capped per tick). Rules during this window:
- Writes: if the hash is known, write hash-keyed. If not, write
`(library_id, rel_path)`-keyed and let the reconciliation job collapse
duplicates once the hash lands.
- Reads: prefer hash key, fall back to `(library_id, rel_path)`.
- Reconciliation: a one-shot pass after every backfill tick collapses
rows that now share a hash, applying the merge semantics above.
Idempotent — safe to re-run.
**Library handoff (recent → archive).** When a file moves between
libraries (e.g. operator moves `~/photos/2024/IMG.nef` to the archive
mount), the file watcher sees the disappearance under lib1 and the
appearance under lib2. Hash-keyed rows don't need migration; the
`(library_id, rel_path)` back-ref columns are updated to point to the new
location. Library administrative rows (`entity_photo_links`,
`(library_id, rel_path)` rows in `image_exif` for hash-less items) are
re-keyed by the move detector, which matches a disappearance to an
appearance by `content_hash` within a configurable window.
**Orphans (source deleted while a copy survives).** When the only
`image_exif` row for a hash is deleted (file removed from disk), the
hash-keyed derived rows survive **as long as another `image_exif` row
references the same hash**. If the last reference is gone, derived rows
are eligible for GC (deferred — the GC job runs on a slow schedule so
that a brief unmount or rename doesn't wipe history).
**Stats and counts.** When reporting "how many photos do you have," count
`DISTINCT content_hash` over `image_exif`, not row count. Faces stats
already does this (`FaceDao::stats` in `src/faces.rs`); other counters
should follow suit. Numerator and denominator must live in the same
domain — see the face-stats commentary below for the cautionary tale.
**Per-library scoping when the user asks for it.** A request scoped to
`?library=N` filters the `image_exif` view to that library, and the
hash-keyed derived data is joined through that view. The user sees only
photos that have a copy under lib N, but the derived data attached to
those photos is the merged hash-keyed view. This is the answer to "show
me archive photos with their original tags."
**Operator kill switch (`libraries.enabled`).** Setting `enabled=0` on a
library is a hard pause: the watcher skips it entirely — before the
probe, before ingest, before any maintenance pass — and the orphan-GC
all-online consensus check filters disabled libraries out (they don't
keep the GC window closed). Reads / serving are unaffected; nothing
prevents `/image?path=...` from resolving against a disabled library's
root if the file is on disk. The existing `image_exif` rows for a
disabled library are **not deleted** — they continue to anchor
hash-keyed derived data, so cross-library duplicates survive the
disable. Toggle via SQL; there is intentionally no HTTP endpoint for
library mutation (single-user tool, no role / permission story).
Typical workflows: stage a new mount with `enabled=0` then flip to `1`;
quiet a flaky NAS during maintenance without disturbing the rest of
the system.
**Per-library excludes (`libraries.excluded_dirs`).** A
comma-separated column, same shape as the global `EXCLUDED_DIRS` env
var, that's applied **in union** with the env-var globals when a
walker scans this library. Use case: mount a parent directory as a
new library while a sibling library covers a child subtree, and
exclude that child subtree from the parent so the two libraries
don't double-walk and double-write `image_exif`. Two entry forms
(parsed by `memories::PathExcluder`):
- `/sub/path` — leading slash flags it as a path under the library
root. Joins to root + matches by `path.starts_with(...)`. Works
at any depth (`/photos`, `/media/2024/raw`).
- `name` — no leading slash flags it as a component name to skip
anywhere in the tree (`@eaDir`, `.thumbnails`). Single segment
only — `media/photos/a` without a leading slash never matches
anything. Hash-keyed derived
data (faces, tags, insights) is unaffected either way — those
follow the bytes — but `image_exif` row count, walker CPU, and
thumbnail disk usage all drop to 1× instead of 2× for the overlap.
Affects: file-watch ingest (`process_new_files`), thumbnail
generation, media-count gauges, the orphaned-playlist cleanup walk,
and the `/memories` endpoint. The face-detection backlog drain
inherits via `face_watch::filter_excluded`. NULL = no extras (only
the global env var applies).
**Library availability and safety.** Libraries can be on network shares
or removable media; the file watcher must not interpret a temporary
unavailability as a mass-deletion event. Every tick begins with a
**presence probe** per library: the library is considered online iff
its `root_path` exists, is readable, and a top-level scan returns at
least one expected entry (or matches a recent file-count high-water
mark within a tolerance). The probe result gates which actions are safe
to run on that library this tick:
| Action | Requires online? |
|---|---|
| Quick / full scan ingest of new files | yes |
| EXIF / face / insight backlog drains | yes — but the work runs against any online library |
| Move-handoff detection (lib1 disappearance ↔ lib2 appearance match) | **both** libraries online |
| `(library_id, rel_path)` re-keying on detected move | **both** libraries online |
| Orphan GC of hash-keyed derived data | all libraries that have *ever* held the hash must be online and confirmed-clean for two consecutive ticks |
| Reads / serving | always allowed; falls back to whichever library is online |
A library that fails the probe enters a "stale" state: writes scoped to
it are paused, its rows are flagged stale (not deleted) in
`/libraries` status, and the watcher logs at `warn` once per
state-transition (not per tick). A library that recovers re-enters the
online set automatically; no operator action required for transient
outages. The intent is that pulling a USB drive, rebooting a NAS, or
losing a VPN never triggers a destructive code path — the worst case is
that derived-data work pauses until the share returns.
The same rule constrains the move-handoff matcher: a disappearance
under lib1 only counts as a "move" if there is a matching appearance
under another **online** library within the window. A bare
disappearance with no matching appearance is treated as
"unavailable-or-deleted, defer judgment" — it does not re-key any rows
and does not enqueue GC.
**Maintenance pipeline (`src/library_maintenance.rs`).** The watcher
runs three maintenance passes per tick that together implement the
move/handoff and orphan rules:
1. **Missing-file scan** — per online library, paginated. A page of
`image_exif` rows is loaded (`IMAGE_EXIF_MISSING_SCAN_PAGE_SIZE`,
default 500), each row's `(root_path/rel_path)` is `stat()`-ed,
and confirmed-not-found rows are deleted from `image_exif`
(capped at `IMAGE_EXIF_MISSING_DELETE_CAP_PER_TICK`, default 200).
Permission/IO errors are skipped, never deleted — only `NotFound`
triggers a deletion. The cursor wraps every time a partial page
comes back, so the whole library is swept across consecutive ticks.
Skipped wholesale for Stale libraries via the per-library probe
gate at the top of the loop iteration.
2. **Back-ref refresh** — DB-only. For `face_detections`,
`tagged_photo`, and `photo_insights`: any hash-keyed row whose
`(library_id, rel_path)` no longer matches an `image_exif` row
*but whose `content_hash` does* is repointed at the surviving
`image_exif` location. Idempotent SQL; no health gate needed.
This is what makes the recent → archive handoff invisible to
read paths: when the missing-file scan retires the lib-A row,
tags/faces/insights pivot to lib-B's path before any user
notices.
3. **Orphan GC** — destructive. Hash-keyed derived rows whose
`content_hash` no longer has any `image_exif` row are eligible.
Two-tick consensus: a hash must be observed orphaned on two
consecutive ticks AND every library must be online for both. A
single Stale tick within the window cancels all pending deletes.
The pending set is held in memory (`OrphanGcState`) — restart
resets it, which only delays a delete, never causes one. Tags,
faces, and insights for orphaned hashes are deleted in one batch
per tick.
A backup library that briefly disappears, then returns within two
ticks, never loses any derived data. A move from lib-A to lib-B
without disappearance flips through pass 1 (lib-A row retired) and
pass 2 (back-refs follow), with pass 3 noting nothing because the
hash is still present in `image_exif` (lib-B's row).
**Known gap: in-place content changes (future Branch D).** The
maintenance pipeline assumes a `(library_id, rel_path)`'s bytes are
stable for as long as the file exists at that path. If a user edits
a file in place (crop, re-export) without renaming, the watcher's
quick scan walks the file (mtime is recent) but `process_new_files`
short-circuits because `(library_id, rel_path)` already has an
`image_exif` row — no re-hash, no re-EXIF, no face redetection. The
row's `content_hash` keeps pointing at the original bytes. Tags /
faces / insights stay attached to the original hash and continue to
display because the rel_path back-ref still resolves; new faces
introduced by the edit are never detected.
The right place to fix this is a **stale-content detection pass**
that compares `image_exif.last_modified` / `size_bytes` to
`fs::metadata` for rows the quick scan would otherwise skip. On
mismatch, recompute the hash, update `image_exif`, and apply the
"content branched" semantics:
- **Faces** re-run (faces are fully derived from bytes).
- **Tags** migrate to the new hash (user intent — "this photo is
vacation" survives a crop). Insights migrate forward as a
starting point and are flagged for re-generation.
- **Favorites** (when migrated to hash-keyed) follow the path /
user intent.
The interesting case is the operator who keeps an unedited copy in
the archive library and edits the local copy: post-detection, the
archive copy stays on the original hash, the local copy branches to
the new hash, and the two histories cleanly split. Apollo's
`derived.db` cache will need an invalidation hook for the changed
hash — design it alongside Branch D.
### File Processing Pipeline ### File Processing Pipeline
**Thumbnail Generation:** **Thumbnail Generation:**
@@ -128,6 +367,60 @@ Runs in background thread with two-tier strategy:
- Batch queries EXIF DB to detect new files - Batch queries EXIF DB to detect new files
- Configurable via `WATCH_QUICK_INTERVAL_SECONDS` and `WATCH_FULL_INTERVAL_SECONDS` - Configurable via `WATCH_QUICK_INTERVAL_SECONDS` and `WATCH_FULL_INTERVAL_SECONDS`
**Canonical date_taken pipeline (`src/date_resolver.rs`).** Every row's
`image_exif.date_taken` is populated at ingest by a four-step waterfall;
which step won is recorded in `image_exif.date_taken_source` so the
per-tick drain can re-resolve weak entries when better tools become
available, and so the UI/debug surface can answer "why did this photo
land on this date?". Order:
1. **`exif`** — kamadak-exif `DateTime` / `DateTimeOriginal`. Fast,
in-process, image-only.
2. **`exiftool`** — shell-out fallback for tags kamadak can't reach:
QuickTime/MP4 (`MediaCreateDate`, `TrackCreateDate`, `CreateDate`),
Apple's `ContentCreateDate`, MakerNote sub-IFDs. Required for
videos to land a real date. Single-file at ingest; the per-tick
drain feeds the whole batch through one `exiftool -@ -` subprocess.
Degrades silently when `exiftool` isn't on PATH (resolver caches the
"available" check via `OnceLock`).
3. **`filename`** — `extract_date_from_filename` in `memories.rs`
matches screenshot, chat-export, and timestamp-named patterns.
4. **`fs_time`** — `earliest_fs_time(metadata)` (earlier of created /
modified). Last resort.
Notable behavior change vs. the pre-2026-05 request-time logic:
**EXIF beats filename when both are present.** A photo named
`Screenshot_2014-06-01.png` whose EXIF `DateTime` is 2021 now appears
under 2021, not 2014 — on the theory that EXIF is more reliable than
import-named filenames. The reverse case (no EXIF, filename has a
date) is unchanged.
The `backfill_missing_date_taken` drain (`src/backfill.rs`) runs every
watcher tick alongside `backfill_unhashed_backlog` (also `src/backfill.rs`). It loads up to
`DATE_BACKFILL_MAX_PER_TICK` rows (default 500) where
`date_taken IS NULL` (backed by the `idx_image_exif_date_backfill`
partial index), runs the waterfall batch via `resolve_dates_batch`,
and writes results via the `backfill_date_taken` DAO method (touches
only `date_taken` + `date_taken_source` so EXIF / hash / perceptual
columns are preserved). Resolved rows — including the ones the
waterfall could only resolve via `fs_time` — are not re-eligible:
the resolver is deterministic on file bytes + filename + fs metadata,
so re-running on the same inputs lands on the same source every time.
An earlier version included `date_taken_source = 'fs_time'` in the
eligibility predicate, but with `ORDER BY id ASC LIMIT 500` it spun on
the same lowest-id rows in perpetuity and held the SQLite write lock
long enough to starve face-PATCH writers (5s busy_timeout → 500). If
a stronger tool comes online (exiftool install, new filename regex),
re-resolve out-of-band rather than re-introducing the steady-state
eligibility.
`/memories` is a single SQL query against this column
(`get_memories_in_window` in `src/database/mod.rs`), using
`strftime('%m-%d' | '%W' | '%m', date_taken, 'unixepoch', tz)` for
calendar matching with the client's timezone offset. The pre-rewrite
version stat'd every row and walked the entire library tree — at
~14k photos this took 1015 s; the rewrite is single-digit ms.
**EXIF Extraction:** **EXIF Extraction:**
- Uses `kamadak-exif` crate - Uses `kamadak-exif` crate
- Supports: JPEG, TIFF, RAW (NEF, CR2, CR3), HEIF/HEIC, PNG, WebP - Supports: JPEG, TIFF, RAW (NEF, CR2, CR3), HEIF/HEIC, PNG, WebP
@@ -180,10 +473,16 @@ GET /memories?path=...&recursive=true
POST /insights/generate (non-agentic single-shot) POST /insights/generate (non-agentic single-shot)
POST /insights/generate/agentic (tool-calling loop; body: { file_path, backend?, model?, ... }) POST /insights/generate/agentic (tool-calling loop; body: { file_path, backend?, model?, ... })
GET /insights?path=...&library=... GET /insights?path=...&library=...
GET /insights/models (local Ollama models + capabilities) GET /insights/models (local-backend models + capabilities; Ollama OR llama-swap based on LLM_BACKEND)
GET /insights/openrouter/models (curated OpenRouter allowlist) GET /insights/openrouter/models (curated OpenRouter allowlist)
POST /insights/rate (thumbs up/down for training data) POST /insights/rate (thumbs up/down for training data)
// Text-to-Speech (Chatterbox via llama-swap; needs LLAMA_SWAP_URL)
POST /tts/speech (read-aloud: { text, voice?, ... } -> { audio_base64, format })
GET /tts/voices (Chatterbox voice library)
POST /tts/voices/upload (clone a voice from an uploaded clip; multipart)
POST /tts/voices/from-library (clone a voice from a library audio/video file)
// Insight Chat Continuation // Insight Chat Continuation
POST /insights/chat (single-turn reply, non-streaming) POST /insights/chat (single-turn reply, non-streaming)
POST /insights/chat/stream (SSE: text / tool_call / tool_result / truncated / done) POST /insights/chat/stream (SSE: text / tool_call / tool_result / truncated / done)
@@ -219,11 +518,11 @@ ImageApi owns the face data; Apollo (sibling repo) hosts the insightface inferen
- `persons(id, name UNIQUE COLLATE NOCASE, cover_face_id, entity_id, created_from_tag, notes, ...)` — operator-managed, name is the user-visible identity. - `persons(id, name UNIQUE COLLATE NOCASE, cover_face_id, entity_id, created_from_tag, notes, ...)` — operator-managed, name is the user-visible identity.
- `face_detections(id, library_id, content_hash, rel_path, bbox_*, embedding BLOB, confidence, source, person_id, status, model_version, ...)` — keyed on `content_hash` so a photo duplicated across libraries is detected once. Marker rows for `status IN ('no_faces','failed')` carry NULL bbox/embedding (CHECK constraint enforces this). - `face_detections(id, library_id, content_hash, rel_path, bbox_*, embedding BLOB, confidence, source, person_id, status, model_version, ...)` — keyed on `content_hash` so a photo duplicated across libraries is detected once. Marker rows for `status IN ('no_faces','failed')` carry NULL bbox/embedding (CHECK constraint enforces this).
**Why content_hash and not (library_id, rel_path):** ties face data to the bytes, not the path. A backup mount that copies files from the primary library naturally inherits the existing detections without re-running inference. **Why content_hash and not (library_id, rel_path):** ties face data to the bytes, not the path. A backup mount that copies files from the primary library naturally inherits the existing detections without re-running inference. This is the reference implementation of the multi-library data model — see "Multi-library data model" above.
**File-watch hook** (`src/main.rs::process_new_files`): for each photo with a populated `content_hash`, check `FaceDao::already_scanned(hash)`; if not, send bytes (or embedded JPEG preview for RAW via `exif::extract_embedded_jpeg_preview`) to Apollo's `/api/internal/faces/detect`. K=`FACE_DETECT_CONCURRENCY` (default 8) parallel calls per scan tick; Apollo serializes them via its single-worker GPU pool. `face_watch.rs` is the Tokio orchestration layer. **File-watch hook** (`src/watcher.rs::process_new_files`): for each photo with a populated `content_hash`, check `FaceDao::already_scanned(hash)`; if not, send bytes (or embedded JPEG preview for RAW via `exif::extract_embedded_jpeg_preview`) to Apollo's `/api/internal/faces/detect`. K=`FACE_DETECT_CONCURRENCY` (default 8) parallel calls per scan tick; Apollo serializes them via its single-worker GPU pool. `face_watch.rs` is the Tokio orchestration layer.
**Per-tick backlog drain** (also `src/main.rs`): two passes that run on every watcher tick regardless of quick-vs-full scan: **Per-tick backlog drain** (`src/backfill.rs`): two passes that run on every watcher tick regardless of quick-vs-full scan:
- `backfill_unhashed_backlog` — populates `image_exif.content_hash` for photos that arrived before the hash field was retroactive. Capped by `FACE_HASH_BACKFILL_MAX_PER_TICK` (default 2000); errors don't burn the cap. - `backfill_unhashed_backlog` — populates `image_exif.content_hash` for photos that arrived before the hash field was retroactive. Capped by `FACE_HASH_BACKFILL_MAX_PER_TICK` (default 2000); errors don't burn the cap.
- `process_face_backlog` — runs detection on photos that have a hash but no `face_detections` row. Capped by `FACE_BACKLOG_MAX_PER_TICK` (default 64). Selected via a SQL anti-join (`FaceDao::list_unscanned_candidates`); videos and EXCLUDED_DIRS paths filtered out client-side via `face_watch::filter_excluded` so they never reach Apollo. - `process_face_backlog` — runs detection on photos that have a hash but no `face_detections` row. Capped by `FACE_BACKLOG_MAX_PER_TICK` (default 64). Selected via a SQL anti-join (`FaceDao::list_unscanned_candidates`); videos and EXCLUDED_DIRS paths filtered out client-side via `face_watch::filter_excluded` so they never reach Apollo.
@@ -233,9 +532,13 @@ ImageApi owns the face data; Apollo (sibling repo) hosts the insightface inferen
**Rerun preserves manual rows** (`POST /image/faces/{id}/rerun`): only `source='auto'` rows are deleted before re-running detection. `already_scanned` returns true on ANY row, so a photo whose only faces are manually drawn never auto-redetects. **Rerun preserves manual rows** (`POST /image/faces/{id}/rerun`): only `source='auto'` rows are deleted before re-running detection. `already_scanned` returns true on ANY row, so a photo whose only faces are manually drawn never auto-redetects.
**Stats domain — content_hash, not file rows** (`FaceDao::stats` in `src/faces.rs`): `total_photos` counts `DISTINCT content_hash` over `image_exif` (filtered to image extensions, `content_hash IS NOT NULL`), and so do `scanned` / `with_faces` / `no_faces` / `failed` over `face_detections`. Numerator and denominator must live in the same domain — `face_detections` is keyed on content_hash, so the same JPEG present at two rel_paths or in two libraries scans once. Counting `image_exif` rows in the denominator inflated total by one per duplicate file and produced a permanent gap (e.g. 1101/1103 with nothing actually pending). Hash-less rows are excluded from total_photos while they sit in the `backfill_unhashed_backlog` queue; otherwise the bar pins below 100% for the duration of that backfill even though those rows aren't pending detection yet — they're pending hashing.
Module map: Module map:
- `src/faces.rs``FaceDao` trait + `SqliteFaceDao` impl, route handlers for `/faces/*`, `/image/faces/*`, `/persons/*`. Mirror of `tags.rs` layout. - `src/faces.rs``FaceDao` trait + `SqliteFaceDao` impl, route handlers for `/faces/*`, `/image/faces/*`, `/persons/*`. Mirror of `tags.rs` layout.
- `src/face_watch.rs` — Tokio orchestration for the file-watch detect pass; `filter_excluded` (PathExcluder + image-extension filter), `read_image_bytes_for_detect` (RAW preview fallback). - `src/face_watch.rs` — Tokio orchestration for the file-watch detect pass; `filter_excluded` (PathExcluder + image-extension filter), `read_image_bytes_for_detect` (RAW preview fallback).
- `src/backfill.rs` — per-tick drains (unhashed-hash, date_taken, face-backlog, etc.) called from `watcher::watch_files` and `watcher::process_new_files`.
- `src/watcher.rs` — the watcher loop itself and `process_new_files` (file walk → EXIF write → face-candidate build).
- `src/ai/face_client.rs` — HTTP client for Apollo's inference. Configured by `APOLLO_FACE_API_BASE_URL`, falls back to `APOLLO_API_BASE_URL`. Both unset → feature disabled, file-watch hook is a no-op. - `src/ai/face_client.rs` — HTTP client for Apollo's inference. Configured by `APOLLO_FACE_API_BASE_URL`, falls back to `APOLLO_API_BASE_URL`. Both unset → feature disabled, file-watch hook is a no-op.
- `migrations/2026-04-29-000000_add_faces/` — schema. - `migrations/2026-04-29-000000_add_faces/` — schema.
@@ -296,6 +599,7 @@ Optional:
```bash ```bash
WATCH_QUICK_INTERVAL_SECONDS=60 # Quick scan interval WATCH_QUICK_INTERVAL_SECONDS=60 # Quick scan interval
WATCH_FULL_INTERVAL_SECONDS=3600 # Full scan interval WATCH_FULL_INTERVAL_SECONDS=3600 # Full scan interval
DATE_BACKFILL_MAX_PER_TICK=500 # Cap on canonical-date drain per watcher tick
OTLP_OTLS_ENDPOINT=http://... # OpenTelemetry collector (release builds) OTLP_OTLS_ENDPOINT=http://... # OpenTelemetry collector (release builds)
# AI Insights Configuration # AI Insights Configuration
@@ -333,8 +637,55 @@ OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small # Optional, embeddings
OPENROUTER_HTTP_REFERER=https://your-site.example # Optional attribution header OPENROUTER_HTTP_REFERER=https://your-site.example # Optional attribution header
OPENROUTER_APP_TITLE=ImageApi # Optional attribution header OPENROUTER_APP_TITLE=ImageApi # Optional attribution header
# Local LLM backend switch. `ollama` (default) keeps the OLLAMA_* settings
# above; `llamacpp` swaps the entire local stack (chat + vision describe +
# embeddings) over to llama-swap. The switch is global and applies to
# `backend=local` requests and to `backend=hybrid`'s describe pass (hybrid
# chat still goes to OpenRouter). Don't flip mid-deploy without
# re-embedding — mixed vector spaces break similarity search.
LLM_BACKEND=ollama
# Embedding model contract. Corpus and queries must be embedded by the same
# model with matching prefixes — after changing the embed model or any of
# these, run `cargo run --bin reembed_embeddings` (all tables) or search is
# garbage. Prefix values may contain a literal \n (expanded to a newline).
EMBEDDING_DIM=768 # 768 = nomic-embed-text v1.5; 1024 = Qwen3-Embedding-0.6B
EMBED_QUERY_PREFIX= # nomic: "search_query: " | Qwen3: "Instruct: <task>\nQuery: "
EMBED_DOCUMENT_PREFIX= # nomic: "search_document: " | Qwen3: leave empty
# llama.cpp / llama-swap (used when LLM_BACKEND=llamacpp). OpenAI-compatible
# proxy hosting one or more llama-server processes. Chat models receive
# images directly via content-parts (all models assumed vision-capable).
LLAMA_SWAP_URL=http://localhost:9292/v1 # Required when LLM_BACKEND=llamacpp
LLAMA_SWAP_PRIMARY_MODEL=chat # Chat slot id (matches config.yaml)
LLAMA_SWAP_VISION_MODEL= # Dedicated vision slot for describe_image / describe_photo
# tool. Defaults to PRIMARY_MODEL when unset.
LLAMA_SWAP_EMBEDDING_MODEL=embed # Embedding slot id
LLAMA_SWAP_ALLOWED_MODELS=chat,coder # Curated allowlist surfaced by GET /insights/models
# when LLM_BACKEND=llamacpp. All report has_vision=true.
# Empty = picker shows only the configured primary model.
LLAMA_SWAP_REQUEST_TIMEOUT_SECONDS=180 # Per-request timeout; bump for slow CPU offload
# Text-to-speech (Chatterbox served behind llama-swap). Only needs
# LLAMA_SWAP_URL — independent of LLM_BACKEND. Powers /tts/speech (read-aloud)
# and /tts/voices* (voice cloning). Reference audio is ffmpeg-normalized to WAV
# server-side, so any source format works.
LLAMA_SWAP_TTS_MODEL=chatterbox # TTS model id in config.yaml (default: chatterbox)
LLAMA_SWAP_TTS_VOICE=m # Default voice when /tts/speech omits one (optional)
LLAMA_SWAP_TTS_REF_SECONDS=30 # Max voice-clone reference clip length, seconds
# (Chatterbox is zero-shot; ~10-20s clean ref is ideal)
LLAMA_SWAP_TTS_REQUEST_TIMEOUT_SECONDS=600 # Per-request synth timeout (long chunked insights take
# minutes); overrides the shared client timeout for /tts/speech
TTS_PRONUNCIATIONS_PATH=tts_pronunciations.json # JSON map of pronunciation overrides applied before synth
# (see tts_pronunciations.example.json); hot-reloaded on change
# Insight Chat Continuation # Insight Chat Continuation
AGENTIC_CHAT_MAX_ITERATIONS=6 # Cap on tool-calling iterations per chat turn (default 6) AGENTIC_CHAT_MAX_ITERATIONS=6 # Cap on tool-calling iterations per chat turn (default 6)
AGENTIC_CHAT_DEFAULT_NUM_CTX=32768 # Assumed context window for the history-truncation budget
# when a chat request omits num_ctx (default 32768). Size to
# the smallest context among the chat models actually served;
# too small silently guts replayed history every turn (and
# destroys llama.cpp KV-cache prefix reuse).
``` ```
**AI Insights Fallback Behavior:** **AI Insights Fallback Behavior:**
@@ -352,10 +703,50 @@ The `OllamaClient` provides methods to query available models:
This allows runtime verification of model availability before generating insights. This allows runtime verification of model availability before generating insights.
**Local backend switch (`LLM_BACKEND`):**
One env var decides which "local" stack the server runs against — `ollama`
(default) or `llamacpp`. It's global on purpose: chat, vision, and
embeddings all route through the same backend, so the embedding-vector
column in SQLite stays in one vector space. Don't flip mid-deploy without
re-embedding the affected rows — similarity search will collapse.
- `LLM_BACKEND=ollama`: chat, vision, and embeddings use Ollama. Vision
capability is probed per-model via `/api/show`.
- `LLM_BACKEND=llamacpp`: chat models receive images directly via OpenAI
content-parts (all models assumed vision-capable). Embeddings hit the
`embed` slot. A dedicated `LLAMA_SWAP_VISION_MODEL` slot (defaults to
the chat model) handles `describe_image` for the `describe_photo` tool.
Requires `LLAMA_SWAP_URL`.
The per-request `backend=hybrid` override is orthogonal: it always sends
chat to OpenRouter (text-only, images are pre-described and inlined), but
the describe + embed passes still route through whichever `LLM_BACKEND`
is configured.
**Backend dispatch (`ResolvedBackend`):**
`InsightGenerator::resolve_backend(kind, overrides)` is the single entry
point that builds clients for a request. Returns a `ResolvedBackend` with
two roles: `.chat()` (the agentic/chat client) and `.local()` (local-only
utility calls: rerank, describe_image, embeddings). `BackendKind` is an
enum (`Local` | `Hybrid`) replacing the stringly-typed `"local"` /
`"hybrid"` labels. `SamplingOverrides` groups model/ctx/temp/top_p/top_k/
min_p per-request overrides. All downstream code (`execute_tool`,
`run_streaming_agentic_loop`, etc.) takes `&ResolvedBackend` rather than
individual client references.
`GET /insights/models` returns the local-backend models with capabilities
in the same envelope shape regardless of `LLM_BACKEND`: Ollama servers
when `ollama`, llama-swap slots (from `LLAMA_SWAP_ALLOWED_MODELS`) when
`llamacpp`. No `/insights/llamacpp/models` — the picker reads a single
endpoint.
**Hybrid Backend (OpenRouter):** **Hybrid Backend (OpenRouter):**
- Per-request opt-in via `backend=hybrid` on `POST /insights/generate/agentic`. - Per-request opt-in via `backend=hybrid` on `POST /insights/generate/agentic`.
- Local Ollama still describes the image (vision); the description is inlined - Vision describe happens before the agentic loop; the description is inlined
into the chat prompt and the agentic loop runs on OpenRouter. into the chat prompt and the agentic loop runs on OpenRouter. Vision
routes through whichever `LLM_BACKEND` is configured.
- `request.model` (if provided) overrides `OPENROUTER_DEFAULT_MODEL` for that - `request.model` (if provided) overrides `OPENROUTER_DEFAULT_MODEL` for that
call. The mobile picker reads from `OPENROUTER_ALLOWED_MODELS`. call. The mobile picker reads from `OPENROUTER_ALLOWED_MODELS`.
- No live capability precheck — the operator-curated allowlist is trusted. - No live capability precheck — the operator-curated allowlist is trusted.
@@ -363,6 +754,15 @@ This allows runtime verification of model availability before generating insight
- `GET /insights/openrouter/models` returns `{ models, default_model, configured }` - `GET /insights/openrouter/models` returns `{ models, default_model, configured }`
for client picker UIs. for client picker UIs.
**Cross-replay matrix (chat continuation):**
- `local → local` allowed (whether served by Ollama or llama-swap; that's
a deploy-time decision, not a request-time one).
- `hybrid → hybrid` allowed.
- `hybrid → local` allowed (the inlined description replays as text).
- `local → hybrid` rejected — the stored transcript has raw images in the
first user message and OpenRouter providers don't accept that shape
consistently. Regenerate the insight in hybrid mode instead.
**Insight Chat Continuation:** **Insight Chat Continuation:**
After an agentic insight is generated, the full `Vec<ChatMessage>` transcript is After an agentic insight is generated, the full `Vec<ChatMessage>` transcript is
@@ -372,7 +772,12 @@ clients whether chat is available for a given insight.
- `POST /insights/chat` runs one turn of the agentic loop against the replayed - `POST /insights/chat` runs one turn of the agentic loop against the replayed
history. Body: `{ file_path, library?, user_message, model?, backend?, num_ctx?, history. Body: `{ file_path, library?, user_message, model?, backend?, num_ctx?,
temperature?, top_p?, top_k?, min_p?, max_iterations?, amend? }`. temperature?, top_p?, top_k?, min_p?, max_iterations?, system_prompt?, amend? }`.
`system_prompt` is a per-turn override: in append mode (default) it's applied
ephemerally — the original system message is restored before persistence so
the stored transcript keeps its baked persona. In amend mode the override
stays in place and becomes the new insight row's system message. Mirrors the
internal `annotate_system_with_budget` swap-and-restore pattern.
- `POST /insights/chat/stream` is the SSE variant — same request body, response - `POST /insights/chat/stream` is the SSE variant — same request body, response
is `text/event-stream` with events: `iteration_start`, `text` (delta), `tool_call`, is `text/event-stream` with events: `iteration_start`, `text` (delta), `tool_call`,
`tool_result`, `truncated`, `done`, plus a server-emitted `error_message` on `tool_result`, `truncated`, `done`, plus a server-emitted `error_message` on
@@ -404,14 +809,17 @@ Per-`(library_id, file_path)` async mutex (`AppState.insight_chat.chat_locks`)
serialises concurrent turns on the same insight so the JSON blob doesn't race. serialises concurrent turns on the same insight so the JSON blob doesn't race.
Context management is a soft bound: if the serialized history exceeds Context management is a soft bound: if the serialized history exceeds
`num_ctx - 2048` tokens (cheap 4-byte/token heuristic), the oldest `num_ctx - 2048` tokens (cheap 4-byte/token heuristic; `num_ctx` defaults
assistant-tool_call + tool_result pairs are dropped until under budget. The to `AGENTIC_CHAT_DEFAULT_NUM_CTX`, 32768, when the request omits it), the
oldest assistant-tool_call + tool_result pairs are dropped until under budget. The
initial user message (with any images) and system prompt are always preserved. initial user message (with any images) and system prompt are always preserved.
The `truncated` event / flag is surfaced to the client when a drop occurred. The `truncated` event / flag is surfaced to the client when a drop occurred.
Configurable env: Configurable env:
- `AGENTIC_CHAT_MAX_ITERATIONS` — cap on tool-calling iterations per turn - `AGENTIC_CHAT_MAX_ITERATIONS` — cap on tool-calling iterations per turn
(default 6). Per-request `max_iterations` is clamped to this cap. (default 6). Per-request `max_iterations` is clamped to this cap.
- `AGENTIC_CHAT_DEFAULT_NUM_CTX` — assumed context window for the truncation
budget when the request omits `num_ctx` (default 32768).
**Apollo Places integration (optional):** **Apollo Places integration (optional):**
Generated
+1101 -861
View File
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "image-api" name = "image-api"
version = "1.1.0" version = "1.4.0"
authors = ["Cameron Cordes <cameronc.dev@gmail.com>"] authors = ["Cameron Cordes <cameronc.dev@gmail.com>"]
edition = "2024" edition = "2024"
@@ -9,6 +9,9 @@ edition = "2024"
[profile.release] [profile.release]
lto = "thin" lto = "thin"
[profile.dev]
debug = "line-tables-only"
[dependencies] [dependencies]
actix = "0.13.1" actix = "0.13.1"
actix-web = "4" actix-web = "4"
@@ -23,7 +26,7 @@ jsonwebtoken = "9.3.0"
serde = "1" serde = "1"
serde_json = "1" serde_json = "1"
diesel = { version = "2.2.10", features = ["sqlite"] } diesel = { version = "2.2.10", features = ["sqlite"] }
libsqlite3-sys = { version = "0.35", features = ["bundled"] } libsqlite3-sys = "0.35"
diesel_migrations = "2.2.0" diesel_migrations = "2.2.0"
chrono = "0.4" chrono = "0.4"
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
@@ -59,5 +62,13 @@ ical = "0.11"
scraper = "0.20" scraper = "0.20"
base64 = "0.22" base64 = "0.22"
blake3 = "1.5" blake3 = "1.5"
image_hasher = "3.0"
bk-tree = "0.5"
async-trait = "0.1" async-trait = "0.1"
indicatif = "0.17" indicatif = "0.17"
uuid = { version = "1.10", features = ["v4", "serde"] }
# Windows lacks system sqlite3, so re-enable the bundled C build there.
# Linux/macOS use the system library (faster builds, smaller binary).
[target.'cfg(windows)'.dependencies]
libsqlite3-sys = { version = "0.35", features = ["bundled"] }
+50
View File
@@ -147,6 +147,56 @@ so you can rewrite the saved summary from within chat.
- `AGENTIC_CHAT_MAX_ITERATIONS` - Cap on tool-calling iterations per chat turn [default: `6`] - `AGENTIC_CHAT_MAX_ITERATIONS` - Cap on tool-calling iterations per chat turn [default: `6`]
- Per-request `max_iterations` (when sent by the client) is clamped to this cap - Per-request `max_iterations` (when sent by the client) is clamped to this cap
#### Text-to-Speech (Optional)
Reads insights aloud and manages cloned voices via a Chatterbox model served
behind the same llama-swap proxy. Only requires `LLAMA_SWAP_URL` (the TTS client
is built whenever that's set — independent of `LLM_BACKEND`). Endpoints:
- `POST /tts/speech` — body `{ text, voice?, format?, exaggeration?, cfg_weight?,
temperature? }`; returns `{ audio_base64, format }`. Input is cleaned
server-side (markdown + emoji stripped, then pronunciation overrides applied —
see below) and the generation knobs are clamped
to Chatterbox's ranges. Synthesis is serialized (one at a time — the upstream
has no GPU lock of its own); a concurrent request gets a fast `429`.
- `POST /tts/speech/jobs` — durable variant for long syntheses: same body as
`/tts/speech`, returns `202 { job_id, status }` immediately. Jobs queue on the
GPU permit instead of fast-failing `429`.
- `GET /tts/speech/jobs/{id}` — poll a job: `{ job_id, status, format,
audio_base64?, error? }` with status `queued|running|done|error|cancelled`.
Results are kept in memory ~10 min after completion, then the job 404s.
- `DELETE /tts/speech/jobs/{id}` — cancel a queued/running job.
- `GET /tts/voices` — list the voice library. Served from an in-memory cache
(so the listing doesn't make llama-swap spin up the TTS model and evict the
resident LLM); pass `?refresh=1` to force an upstream re-query. The cache is
invalidated by voice create/delete.
- `POST /tts/voices/upload` — multipart `voice_name` + `voice_file`; clone a
voice from an uploaded clip (≤25 MB).
- `POST /tts/voices/from-library` — body `{ voice_name, path, library? }`; clone
from a library file (audio forwarded as-is; video has its audio extracted via
ffmpeg).
- `DELETE /tts/voices/{name}` — remove a cloned voice from the library.
Created voice names are tagged with the ref-clip cap in effect (e.g.
`grandma-30s`) so the library shows which reference length produced each clone.
Words the model mispronounces (place names, initialisms) can be rewritten
before synthesis via a JSON map — copy `tts_pronunciations.example.json` to
`tts_pronunciations.json` and edit; changes apply without a restart. Full
matching rules are documented in `src/ai/pronunciation.rs`.
Env:
- `TTS_PRONUNCIATIONS_PATH` - pronunciation-override JSON file
[default: `tts_pronunciations.json` in the working directory]
- `LLAMA_SWAP_TTS_MODEL` - TTS model id in llama-swap's `config.yaml` [default: `chatterbox`]
- `LLAMA_SWAP_TTS_VOICE` - default voice used when a `/tts/speech` request omits `voice` (optional)
- `LLAMA_SWAP_TTS_REF_SECONDS` - max voice-clone reference clip length in seconds
[default: `30`]. Reference audio is ffmpeg-normalized to mono 24 kHz WAV (so any
source format works); Chatterbox is zero-shot, so a clean ~1020s sample is the
sweet spot — more rarely helps.
- `LLAMA_SWAP_TTS_REQUEST_TIMEOUT_SECONDS` - per-request synthesis timeout in
seconds [default: `600`]. Long insights are chunked + synthesized server-side
and can take minutes; this is separate from (and overrides, for `/tts/speech`)
the shared `LLAMA_SWAP_REQUEST_TIMEOUT_SECONDS`.
#### Fallback Behavior #### Fallback Behavior
- Primary server is tried first with 5-second connection timeout - Primary server is tried first with 5-second connection timeout
- On failure, automatically falls back to secondary server (if configured) - On failure, automatically falls back to secondary server (if configured)
@@ -0,0 +1 @@
DROP INDEX IF EXISTS idx_tags_name_nocase;
@@ -0,0 +1,28 @@
-- Tags only enforced uniqueness in application code (the add_tag handler
-- looks up by name before inserting). The schema itself accepted dupes,
-- so a divergent code path could land two tags with the same name. Now
-- that we expose a rename endpoint we want a hard guarantee: case-
-- insensitive UNIQUE on tags.name.
-- Pre-flight: collapse exact-name duplicates (case-insensitive) onto the
-- lowest-id row before adding the constraint, otherwise the index
-- creation fails on any DB that ever produced dupes. On a clean DB this
-- is a no-op.
UPDATE tagged_photo
SET tag_id = (
SELECT MIN(t2.id) FROM tags t2
WHERE LOWER(t2.name) = LOWER((SELECT name FROM tags WHERE id = tagged_photo.tag_id))
)
WHERE tag_id IN (
SELECT t.id FROM tags t
WHERE t.id <> (
SELECT MIN(t2.id) FROM tags t2 WHERE LOWER(t2.name) = LOWER(t.name)
)
);
DELETE FROM tags
WHERE id <> (
SELECT MIN(t2.id) FROM tags t2 WHERE LOWER(t2.name) = LOWER(tags.name)
);
CREATE UNIQUE INDEX idx_tags_name_nocase ON tags (name COLLATE NOCASE);
@@ -0,0 +1,5 @@
DROP INDEX IF EXISTS idx_photo_insights_content_hash;
ALTER TABLE photo_insights DROP COLUMN content_hash;
DROP INDEX IF EXISTS idx_tagged_photo_content_hash;
ALTER TABLE tagged_photo DROP COLUMN content_hash;
@@ -0,0 +1,64 @@
-- Phase B of the multi-library data-model rollout: add a nullable
-- `content_hash` column to derived/user-intent tables that should follow
-- the bytes rather than the path. Reads will prefer hash-key joins and
-- fall back to rel_path while the column is null. A separate
-- reconciliation pass collapses duplicates as the column populates.
--
-- See CLAUDE.md → "Multi-library data model" for the policy. The
-- reference implementation is `face_detections`, which has been
-- hash-keyed since it was introduced.
--
-- Tables in this migration:
-- * tagged_photo — user-intent (tags follow the bytes)
-- * photo_insights — intrinsic to bytes (LLM-generated description)
--
-- favorites is the natural third candidate but its DAO is barely used in
-- v1 and the row count is tiny; deferring lets this migration stay
-- focused on the high-volume tables that drive cross-library overhead.
-- ---------------------------------------------------------------------------
-- tagged_photo
-- ---------------------------------------------------------------------------
ALTER TABLE tagged_photo ADD COLUMN content_hash TEXT;
-- Backfill: for each tagged_photo row, find the content_hash for its
-- rel_path. tagged_photo doesn't carry a library_id, so a rel_path that
-- exists under multiple libraries with different content is genuinely
-- ambiguous — we take the first matching image_exif row. The
-- reconciliation pass at runtime cleans up any rows that resolve
-- differently once a hash is known per library.
UPDATE tagged_photo
SET content_hash = (
SELECT content_hash FROM image_exif
WHERE image_exif.rel_path = tagged_photo.rel_path
AND image_exif.content_hash IS NOT NULL
LIMIT 1
)
WHERE content_hash IS NULL;
-- Hash-key index. Partial (only non-null rows) to keep the index small
-- during the transitional window where most rows are still null.
CREATE INDEX idx_tagged_photo_content_hash
ON tagged_photo (content_hash)
WHERE content_hash IS NOT NULL;
-- ---------------------------------------------------------------------------
-- photo_insights
-- ---------------------------------------------------------------------------
ALTER TABLE photo_insights ADD COLUMN content_hash TEXT;
-- Backfill keyed on (library_id, rel_path) — photo_insights already
-- carries library_id, so the resolution is unambiguous.
UPDATE photo_insights
SET content_hash = (
SELECT content_hash FROM image_exif
WHERE image_exif.library_id = photo_insights.library_id
AND image_exif.rel_path = photo_insights.rel_path
AND image_exif.content_hash IS NOT NULL
LIMIT 1
)
WHERE content_hash IS NULL;
CREATE INDEX idx_photo_insights_content_hash
ON photo_insights (content_hash)
WHERE content_hash IS NOT NULL;
@@ -0,0 +1,2 @@
-- Requires SQLite 3.35+ for ALTER TABLE DROP COLUMN.
ALTER TABLE libraries DROP COLUMN enabled;
@@ -0,0 +1,14 @@
-- Operator-controlled kill switch for a library. When `enabled = 0` the
-- watcher tick skips that library entirely — before the availability
-- probe, before ingest, before any maintenance pass — and the orphan-GC
-- all-online check treats it as out-of-scope rather than as a blocker.
--
-- The intended workflow is staging a new mount: insert with enabled=0,
-- verify the row appears in /libraries with enabled=false, then UPDATE
-- to 1 to start ingest. Same toggle works as a maintenance kill switch
-- after the fact ("don't keep probing this NAS while I'm rebooting it").
--
-- Default 1 so every existing library stays running on upgrade — no
-- behavior change without an explicit flip.
ALTER TABLE libraries ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT 1;
@@ -0,0 +1,2 @@
-- Requires SQLite 3.35+ for ALTER TABLE DROP COLUMN.
ALTER TABLE libraries DROP COLUMN excluded_dirs;
@@ -0,0 +1,14 @@
-- Per-library excluded directories.
--
-- The global EXCLUDED_DIRS env var is the right knob for excludes that
-- every library shares (Synology @eaDir, .thumbnails, etc.). It's a
-- poor fit for "exclude this subtree from THIS library only", which
-- the natural use case for is mounting a parent directory while
-- another library already covers a child subtree underneath.
--
-- This column is parsed comma-separated, same shape as the env var,
-- and the watcher / memories / thumbnail walks each apply
-- (env_globals library.excluded_dirs) when scanning the library.
-- NULL = no extra excludes; the global env var still applies.
ALTER TABLE libraries ADD COLUMN excluded_dirs TEXT;
@@ -0,0 +1,8 @@
DROP INDEX IF EXISTS idx_image_exif_duplicate_of_hash;
DROP INDEX IF EXISTS idx_image_exif_dhash;
DROP INDEX IF EXISTS idx_image_exif_phash;
ALTER TABLE image_exif DROP COLUMN duplicate_decided_at;
ALTER TABLE image_exif DROP COLUMN duplicate_of_hash;
ALTER TABLE image_exif DROP COLUMN dhash_64;
ALTER TABLE image_exif DROP COLUMN phash_64;
@@ -0,0 +1,41 @@
-- Adds perceptual-hash signals + soft-mark resolution state to image_exif so
-- the duplicates surface in Apollo can group near-duplicates (re-encoded,
-- resized, format-converted copies) and let the user demote losers without
-- touching the file on disk. Image-only for v1: phash_64/dhash_64 are NULL
-- on videos and on images that fail to decode. See Apollo CLAUDE.md →
-- Duplicate detection / Caching layer for the policy.
--
-- Soft-mark columns are media-type-agnostic — when video perceptual hashing
-- arrives, it lives in a separate hash-keyed companion table and reuses the
-- same duplicate_of_hash / duplicate_decided_at machinery.
-- pHash (DCT, 64-bit) packed as i64 for fast XOR + popcount Hamming.
ALTER TABLE image_exif ADD COLUMN phash_64 BIGINT;
-- dHash (gradient, 64-bit). Cheap, robust to compression/resize. Stored
-- alongside pHash so the query layer can fall back if either is null.
ALTER TABLE image_exif ADD COLUMN dhash_64 BIGINT;
-- When non-null, this row is a soft-marked duplicate of the row whose
-- content_hash matches. The duplicate file stays on disk; the default
-- /photos listing filters it out. /photos?include_duplicates=true opts
-- back in (the Apollo duplicates modal uses this).
ALTER TABLE image_exif ADD COLUMN duplicate_of_hash TEXT;
-- Unix seconds of the resolve. Distinguishes "never reviewed" from
-- "reviewed and resolved" for the Apollo include_resolved toggle.
ALTER TABLE image_exif ADD COLUMN duplicate_decided_at BIGINT;
-- Partial indexes — the columns are NULL for the vast majority of rows
-- during the transitional window and forever for videos / decode failures.
CREATE INDEX idx_image_exif_phash
ON image_exif (phash_64)
WHERE phash_64 IS NOT NULL;
CREATE INDEX idx_image_exif_dhash
ON image_exif (dhash_64)
WHERE dhash_64 IS NOT NULL;
CREATE INDEX idx_image_exif_duplicate_of_hash
ON image_exif (duplicate_of_hash)
WHERE duplicate_of_hash IS NOT NULL;
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_image_exif_date_backfill;
ALTER TABLE image_exif DROP COLUMN date_taken_source;
@@ -0,0 +1,24 @@
-- Tracks where a row's `date_taken` was sourced so the canonical-date
-- waterfall (kamadak-exif → exiftool → filename → earliest_fs_time) is
-- visible to debugging and to the per-tick backfill drain that re-runs
-- weak sources once stronger ones become available (e.g. exiftool gets
-- installed on a deploy that didn't have it). See CLAUDE.md → Memories
-- canonical-date pipeline.
--
-- Values:
-- 'exif' — kamadak-exif read DateTime/DateTimeOriginal directly
-- 'exiftool' — exiftool fallback caught a video / MakerNote / QuickTime tag
-- 'filename' — extract_date_from_filename matched a known pattern
-- 'fs_time' — fell through to earliest_fs_time(metadata)
--
-- NULL when `date_taken` itself is NULL (no source resolved the date).
ALTER TABLE image_exif ADD COLUMN date_taken_source TEXT;
-- Partial index for the per-tick backfill drain: targets rows that need
-- re-resolution (no date yet, or only the weakest source resolved it).
-- Filename-sourced rows are intentionally excluded — the regex is
-- authoritative when it matches and re-running exiftool wouldn't change
-- the answer.
CREATE INDEX idx_image_exif_date_backfill
ON image_exif (library_id, id)
WHERE date_taken IS NULL OR date_taken_source = 'fs_time';
@@ -0,0 +1,9 @@
-- Reverting this migration is a no-op: the labels we wrote in `up.sql`
-- are correct under any state of the schema (every dated row was indeed
-- exif-sourced before the resolver landed), and there's no signal that
-- distinguishes "labelled by this migration" from "labelled by the
-- ingest path post-resolver". Clearing them would break the drain's
-- eligibility filter again.
--
-- The companion migration `2026-05-06-000000_add_date_taken_source` is
-- the one to revert if you need to remove the column entirely.
@@ -0,0 +1,20 @@
-- Backfill `date_taken_source` for rows that pre-date the canonical-date
-- pipeline. Before the resolver landed, `image_exif.date_taken` could
-- only be populated via `exif::extract_exif_from_path` (kamadak-exif)
-- on the file-watcher, upload, or GPS-write paths. The resolver column
-- migration added `date_taken_source` defaulting to NULL, so every
-- historical row with a date is currently unlabelled — and the
-- per-tick drain skips them because its eligibility predicate is
-- `date_taken IS NULL OR date_taken_source = 'fs_time'`.
--
-- Label them `'exif'` once and let the drain take over from here. Safe
-- because every code path that wrote `date_taken` prior to the
-- resolver was a kamadak-exif read — there was no other source.
--
-- Idempotent: re-running this migration on a DB that has already been
-- backfilled is a no-op (the WHERE clause matches nothing the second
-- time around).
UPDATE image_exif
SET date_taken_source = 'exif'
WHERE date_taken IS NOT NULL
AND date_taken_source IS NULL;
@@ -0,0 +1,2 @@
ALTER TABLE image_exif DROP COLUMN original_date_taken_source;
ALTER TABLE image_exif DROP COLUMN original_date_taken;
@@ -0,0 +1,15 @@
-- Manual date_taken override: when an operator overrides a row's date via
-- POST /image/exif/date, the prior `(date_taken, date_taken_source)` is
-- snapshotted into these columns and the live columns hold the new value
-- with `date_taken_source = 'manual'`. POST /image/exif/date/clear restores
-- the pair and nulls the originals.
--
-- The waterfall source-name set is now:
-- 'exif' | 'exiftool' | 'filename' | 'fs_time' | 'manual'
--
-- The `idx_image_exif_date_backfill` partial index already filters to
-- `date_taken IS NULL OR date_taken_source = 'fs_time'`, so 'manual' rows
-- are naturally excluded from the per-tick backfill drain — no index
-- change needed.
ALTER TABLE image_exif ADD COLUMN original_date_taken BIGINT;
ALTER TABLE image_exif ADD COLUMN original_date_taken_source TEXT;
@@ -0,0 +1,43 @@
-- Drop the persona-scoping column on entity_facts via the table-rebuild
-- dance for SQLite-version portability (matches the pattern in
-- 2026-04-20-000000_add_backend_to_insights/down.sql).
DROP INDEX IF EXISTS idx_entity_facts_persona;
CREATE TABLE entity_facts_backup AS
SELECT id, subject_entity_id, predicate, object_entity_id, object_value,
source_photo, source_insight_id, confidence, status, created_at
FROM entity_facts;
DROP TABLE entity_facts;
CREATE TABLE entity_facts (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
subject_entity_id INTEGER NOT NULL,
predicate TEXT NOT NULL,
object_entity_id INTEGER,
object_value TEXT,
source_photo TEXT,
source_insight_id INTEGER,
confidence REAL NOT NULL DEFAULT 0.6,
status TEXT NOT NULL DEFAULT 'active',
created_at BIGINT NOT NULL,
CONSTRAINT fk_ef_subject FOREIGN KEY (subject_entity_id) REFERENCES entities(id) ON DELETE CASCADE,
CONSTRAINT fk_ef_object FOREIGN KEY (object_entity_id) REFERENCES entities(id) ON DELETE SET NULL,
CONSTRAINT fk_ef_insight FOREIGN KEY (source_insight_id) REFERENCES photo_insights(id) ON DELETE SET NULL,
CHECK (object_entity_id IS NOT NULL OR object_value IS NOT NULL)
);
INSERT INTO entity_facts
SELECT id, subject_entity_id, predicate, object_entity_id, object_value,
source_photo, source_insight_id, confidence, status, created_at
FROM entity_facts_backup;
DROP TABLE entity_facts_backup;
CREATE INDEX idx_entity_facts_subject ON entity_facts(subject_entity_id);
CREATE INDEX idx_entity_facts_predicate ON entity_facts(predicate);
CREATE INDEX idx_entity_facts_status ON entity_facts(status);
CREATE INDEX idx_entity_facts_source_photo ON entity_facts(source_photo);
DROP INDEX IF EXISTS idx_personas_user;
DROP TABLE IF EXISTS personas;
@@ -0,0 +1,64 @@
-- Personas live server-side now (mobile previously stored them in
-- AsyncStorage only). Each user gets the three built-ins seeded; custom
-- personas land here too via POST /personas or POST /personas/migrate.
--
-- `entity_facts` gains a persona_id so each persona accumulates its own
-- voice over a shared entity graph (entities themselves stay unscoped).
-- Existing rows backfill to 'default' via the column DEFAULT — that
-- becomes the historical baseline. The `include_all_memories` flag on
-- personas lets any persona opt back into reading the full pool.
CREATE TABLE personas (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
user_id INTEGER NOT NULL,
persona_id TEXT NOT NULL,
name TEXT NOT NULL,
system_prompt TEXT NOT NULL,
is_built_in BOOLEAN NOT NULL DEFAULT FALSE,
include_all_memories BOOLEAN NOT NULL DEFAULT FALSE,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
UNIQUE(user_id, persona_id),
CONSTRAINT fk_personas_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_personas_user ON personas(user_id);
-- Seed built-ins for every existing user. System prompts copied verbatim
-- from FileViewer-React/hooks/usePersonas.tsx so server and client agree
-- on the canonical voice for each built-in.
INSERT INTO personas (user_id, persona_id, name, system_prompt, is_built_in, created_at, updated_at)
SELECT
u.id,
'default',
'Default Assistant',
'You are my long-term memory assistant. Use only the information provided. Do not invent details. Respond in 36 sentences in third person, leading with the most concrete moment from the photo and the surrounding context. Plain prose, no headings.',
TRUE,
strftime('%s', 'now') * 1000,
strftime('%s', 'now') * 1000
FROM users u
UNION ALL
SELECT
u.id,
'journal',
'Personal Journal',
'You are a personal journal writer. Write in first person, present tense, with warmth and reflection — focusing on emotions and meaningful moments. Use only the information provided; do not invent details. Aim for 48 sentences in a single flowing paragraph, no headings.',
TRUE,
strftime('%s', 'now') * 1000,
strftime('%s', 'now') * 1000
FROM users u
UNION ALL
SELECT
u.id,
'factual',
'Factual Reporter',
'You are a factual memory recorder. Be precise, objective, and concise. Lead with the date and place, then list what / when / who in 24 short sentences. Use only the information provided; if a detail is unknown, say so rather than guessing.',
TRUE,
strftime('%s', 'now') * 1000,
strftime('%s', 'now') * 1000
FROM users u;
-- Persona scoping on facts only. Entities and entity_photo_links stay
-- shared (real-world referents and shared photo ↔ entity associations).
ALTER TABLE entity_facts ADD COLUMN persona_id TEXT NOT NULL DEFAULT 'default';
CREATE INDEX idx_entity_facts_persona ON entity_facts(persona_id);
@@ -0,0 +1,47 @@
-- Reverse 2026-05-10-000000_entity_facts_persona_fk: drop the
-- composite FK and the user_id column via the same rebuild pattern.
DROP INDEX IF EXISTS idx_entity_facts_user_persona;
DROP INDEX IF EXISTS idx_entity_facts_persona;
DROP INDEX IF EXISTS idx_entity_facts_source_photo;
DROP INDEX IF EXISTS idx_entity_facts_status;
DROP INDEX IF EXISTS idx_entity_facts_predicate;
DROP INDEX IF EXISTS idx_entity_facts_subject;
ALTER TABLE entity_facts RENAME TO entity_facts_old;
CREATE TABLE entity_facts (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
subject_entity_id INTEGER NOT NULL,
predicate TEXT NOT NULL,
object_entity_id INTEGER,
object_value TEXT,
source_photo TEXT,
source_insight_id INTEGER,
confidence REAL NOT NULL DEFAULT 0.6,
status TEXT NOT NULL DEFAULT 'active',
created_at BIGINT NOT NULL,
persona_id TEXT NOT NULL DEFAULT 'default',
CONSTRAINT fk_ef_subject FOREIGN KEY (subject_entity_id) REFERENCES entities(id) ON DELETE CASCADE,
CONSTRAINT fk_ef_object FOREIGN KEY (object_entity_id) REFERENCES entities(id) ON DELETE SET NULL,
CONSTRAINT fk_ef_insight FOREIGN KEY (source_insight_id) REFERENCES photo_insights(id) ON DELETE SET NULL,
CHECK (object_entity_id IS NOT NULL OR object_value IS NOT NULL)
);
INSERT INTO entity_facts
(id, subject_entity_id, predicate, object_entity_id, object_value,
source_photo, source_insight_id, confidence, status, created_at,
persona_id)
SELECT
id, subject_entity_id, predicate, object_entity_id, object_value,
source_photo, source_insight_id, confidence, status, created_at,
persona_id
FROM entity_facts_old;
DROP TABLE entity_facts_old;
CREATE INDEX idx_entity_facts_subject ON entity_facts(subject_entity_id);
CREATE INDEX idx_entity_facts_predicate ON entity_facts(predicate);
CREATE INDEX idx_entity_facts_status ON entity_facts(status);
CREATE INDEX idx_entity_facts_source_photo ON entity_facts(source_photo);
CREATE INDEX idx_entity_facts_persona ON entity_facts(persona_id);
@@ -0,0 +1,82 @@
-- Add a real foreign key from entity_facts to personas. Until now,
-- entity_facts.persona_id was a free-form string with no integrity
-- guarantee — deleting a persona orphaned its facts, which then sat
-- forever in the readable-only-via-PersonaFilter::All hive-mind view.
--
-- personas is keyed (user_id, persona_id) so the FK has to be
-- composite. That requires entity_facts to carry user_id too, which
-- has the side benefit of fixing multi-user fact leakage on the read
-- path (without it, two users with the same 'default' persona would
-- see each other's default-scoped facts).
--
-- SQLite can't ALTER TABLE to add an FK; the table-rebuild dance is
-- the only way. Pattern matches 2026-05-09's down.sql and the older
-- 2026-04-20-000000 migration.
DROP INDEX IF EXISTS idx_entity_facts_subject;
DROP INDEX IF EXISTS idx_entity_facts_predicate;
DROP INDEX IF EXISTS idx_entity_facts_status;
DROP INDEX IF EXISTS idx_entity_facts_source_photo;
DROP INDEX IF EXISTS idx_entity_facts_persona;
ALTER TABLE entity_facts RENAME TO entity_facts_old;
CREATE TABLE entity_facts (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
subject_entity_id INTEGER NOT NULL,
predicate TEXT NOT NULL,
object_entity_id INTEGER,
object_value TEXT,
source_photo TEXT,
source_insight_id INTEGER,
confidence REAL NOT NULL DEFAULT 0.6,
status TEXT NOT NULL DEFAULT 'active',
created_at BIGINT NOT NULL,
persona_id TEXT NOT NULL DEFAULT 'default',
user_id INTEGER NOT NULL DEFAULT 1,
CONSTRAINT fk_ef_subject FOREIGN KEY (subject_entity_id) REFERENCES entities(id) ON DELETE CASCADE,
CONSTRAINT fk_ef_object FOREIGN KEY (object_entity_id) REFERENCES entities(id) ON DELETE SET NULL,
CONSTRAINT fk_ef_insight FOREIGN KEY (source_insight_id) REFERENCES photo_insights(id) ON DELETE SET NULL,
CONSTRAINT fk_ef_persona FOREIGN KEY (user_id, persona_id) REFERENCES personas(user_id, persona_id) ON DELETE CASCADE,
CHECK (object_entity_id IS NOT NULL OR object_value IS NOT NULL)
);
-- Backfill: assign each legacy fact to the user that owns the matching
-- persona. Built-ins are seeded per-user with the same persona_id
-- string for everyone, so MIN(user_id) deterministically picks the
-- earliest registered user (typically user 1, the operator). Custom
-- persona_ids exist for at most one user, so MIN is also unique.
-- Falls back to user_id=1 when no matching persona row exists; in that
-- case the FK below would still fail, but legacy rows shouldn't be in
-- that state because 2026-05-09 ADD COLUMN defaulted persona_id to
-- 'default', which is seeded for every user.
INSERT INTO entity_facts
(id, subject_entity_id, predicate, object_entity_id, object_value,
source_photo, source_insight_id, confidence, status, created_at,
persona_id, user_id)
SELECT
old.id,
old.subject_entity_id,
old.predicate,
old.object_entity_id,
old.object_value,
old.source_photo,
old.source_insight_id,
old.confidence,
old.status,
old.created_at,
old.persona_id,
COALESCE(
(SELECT MIN(p.user_id) FROM personas p WHERE p.persona_id = old.persona_id),
1
)
FROM entity_facts_old old;
DROP TABLE entity_facts_old;
CREATE INDEX idx_entity_facts_subject ON entity_facts(subject_entity_id);
CREATE INDEX idx_entity_facts_predicate ON entity_facts(predicate);
CREATE INDEX idx_entity_facts_status ON entity_facts(status);
CREATE INDEX idx_entity_facts_source_photo ON entity_facts(source_photo);
CREATE INDEX idx_entity_facts_persona ON entity_facts(persona_id);
CREATE INDEX idx_entity_facts_user_persona ON entity_facts(user_id, persona_id);
@@ -0,0 +1,5 @@
-- SQLite can drop columns since 3.35 (March 2021); embedded
-- libsqlite3-sys is well past that. Drop in reverse insert order so
-- a partial down still leaves the schema valid.
ALTER TABLE entity_facts DROP COLUMN valid_until;
ALTER TABLE entity_facts DROP COLUMN valid_from;
@@ -0,0 +1,25 @@
-- Add valid-time columns to entity_facts.
--
-- entity_facts already has created_at — *transaction time*, the
-- moment WE recorded the fact. That's not the same as the real-world
-- period the fact was true. "Cameron is_in_relationship_with X" was
-- only true during a window; recording it in 2026 doesn't make it
-- true today. Without the distinction, every former relationship,
-- former job, former address reads as currently-true.
--
-- Adding two BIGINT NULL columns: valid_from / valid_until (unix
-- seconds). NULL means "unbounded on that side" — `valid_from IS
-- NULL` reads as "always-true-back-to-the-beginning",
-- `valid_until IS NULL` as "still-true-now-or-unknown". Both NULL =
-- temporal validity unknown (current state of all legacy rows).
--
-- Conflict detection refines accordingly: same-predicate facts with
-- different objects stop flagging when their intervals are disjoint
-- ("lives_in NYC 2018-2020" and "lives_in SF 2020-present" are both
-- valid, just at different times).
ALTER TABLE entity_facts ADD COLUMN valid_from BIGINT;
ALTER TABLE entity_facts ADD COLUMN valid_until BIGINT;
-- Optional partial index for time-bounded scans. Skipped for now —
-- conflict detection runs per-entity (small N) and doesn't need it.
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_entity_facts_superseded_by;
ALTER TABLE entity_facts DROP COLUMN superseded_by;
@@ -0,0 +1,31 @@
-- Add a supersession pointer to entity_facts.
--
-- Status alone is a one-way trapdoor: 'rejected' loses the link
-- between the rejected fact and the one that replaced it. For
-- evolving facts (Cameron's relationship, employer, address) the
-- curator wants to *replace* a stale fact with a new one and keep
-- the history readable: "from 2018 until 2022 this was true, then
-- it became this other thing".
--
-- A nullable INTEGER column pointing at another entity_facts.id —
-- no FK constraint because SQLite can't ALTER ADD COLUMN with REFs;
-- the DAO's delete_fact clears dangling pointers in the same
-- transaction as the parent delete to keep the column honest.
--
-- A status of 'superseded' on the old fact (alongside the existing
-- active / reviewed / rejected) signals "replaced by a newer
-- claim". Read paths already filter 'rejected' out of the active
-- view; the curation UI will treat 'superseded' the same way for
-- conflict detection so they don't keep flagging.
--
-- Pairs with the valid-time columns from 2026-05-10-000100: the
-- supersede action auto-stamps the old fact's `valid_until` from
-- the new fact's `valid_from`, closing the interval cleanly.
ALTER TABLE entity_facts ADD COLUMN superseded_by INTEGER;
-- Helpful index for "show me what superseded this fact" walks
-- (rare today; cheap to add now while the table is small).
CREATE INDEX idx_entity_facts_superseded_by
ON entity_facts(superseded_by)
WHERE superseded_by IS NOT NULL;
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_entity_facts_created_by_backend;
DROP INDEX IF EXISTS idx_entity_facts_created_by_model;
ALTER TABLE entity_facts DROP COLUMN created_by_backend;
ALTER TABLE entity_facts DROP COLUMN created_by_model;
@@ -0,0 +1,30 @@
-- Track which model + backend generated each fact so the curator
-- can audit which configurations produce trustworthy knowledge.
--
-- photo_insights already carries `model_version` + `backend`, and
-- entity_facts.source_insight_id links to it — but:
-- 1. source_insight_id is only set after an insight is stored
-- (post-loop), so chat-continuation facts and facts whose insight
-- was regenerated lose the link.
-- 2. JOINing for every read is more friction than just embedding the
-- provenance on the fact row itself.
-- 3. Manual facts (POST /knowledge/facts) have no insight at all and
-- need to record "manual" as their provenance.
--
-- Two nullable TEXT columns are enough for the audit use case: model
-- (e.g. "qwen2.5:7b", "anthropic/claude-sonnet-4") and backend
-- ("local", "hybrid", "manual"). Pre-existing rows leave both NULL —
-- legacy facts predate this tracking and can't be back-filled
-- reliably from training_messages without burning compute.
ALTER TABLE entity_facts ADD COLUMN created_by_model TEXT;
ALTER TABLE entity_facts ADD COLUMN created_by_backend TEXT;
-- Indexes are cheap and useful for "show me all facts from model X"
-- audit queries — partial so the legacy NULL rows don't bloat them.
CREATE INDEX idx_entity_facts_created_by_model
ON entity_facts(created_by_model)
WHERE created_by_model IS NOT NULL;
CREATE INDEX idx_entity_facts_created_by_backend
ON entity_facts(created_by_backend)
WHERE created_by_backend IS NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE personas DROP COLUMN reviewed_only_facts;
@@ -0,0 +1,16 @@
-- Per-persona toggle: when true, agent reads only see facts whose
-- status is exactly 'reviewed' (human-verified). When false (the
-- default), agent reads see 'active' OR 'reviewed' — everything not
-- rejected or superseded.
--
-- The mobile app surfaces this as "Strict mode" on the persona
-- editor: useful when you want a persona's chat to be grounded
-- exclusively on the curated subset, e.g. for tasks where
-- hallucinated agent claims are particularly costly.
--
-- Note: this is separate from `include_all_memories` (which unions
-- across personas for hive-mind reads). Reviewed-only operates on
-- the status axis; include_all_memories operates on the persona-
-- scope axis. They compose freely.
ALTER TABLE personas ADD COLUMN reviewed_only_facts BOOLEAN NOT NULL DEFAULT 0;
@@ -0,0 +1,5 @@
ALTER TABLE personas DROP COLUMN allow_agent_corrections;
DROP INDEX IF EXISTS idx_entity_facts_last_modified_at;
ALTER TABLE entity_facts DROP COLUMN last_modified_at;
ALTER TABLE entity_facts DROP COLUMN last_modified_by_backend;
ALTER TABLE entity_facts DROP COLUMN last_modified_by_model;
@@ -0,0 +1,30 @@
-- Three coupled changes for agent self-correction safety:
--
-- 1. `entity_facts.last_modified_by_*` + `last_modified_at` track who
-- most recently mutated each fact. `created_by_*` from migration
-- 2026-05-10-000300 records who first wrote the row; this records
-- who last *changed* it. Separate columns so the create vs update
-- audit is independently grep-able ("show me every fact gpt-5
-- altered last week" stays a single index scan).
--
-- 2. `personas.allow_agent_corrections` is the gate for the new
-- agent-side `update_fact` / `supersede_fact` tools. Default OFF —
-- a fresh persona's agent can create but can't alter or replace.
-- Operator opts in per-persona after the model has earned trust,
-- typically via the strict-mode flow (curate, then ratchet up
-- agent autonomy as confidence rises). Parallel in shape to
-- `reviewed_only_facts` from 2026-05-10-000400; they compose.
--
-- 3. Index on `last_modified_at` (partial, NOT NULL) for the
-- audit-feed reads in the curation UI ("show recent agent edits
-- sorted newest first").
ALTER TABLE entity_facts ADD COLUMN last_modified_by_model TEXT;
ALTER TABLE entity_facts ADD COLUMN last_modified_by_backend TEXT;
ALTER TABLE entity_facts ADD COLUMN last_modified_at BIGINT;
CREATE INDEX idx_entity_facts_last_modified_at
ON entity_facts(last_modified_at)
WHERE last_modified_at IS NOT NULL;
ALTER TABLE personas ADD COLUMN allow_agent_corrections BOOLEAN NOT NULL DEFAULT 0;
@@ -0,0 +1,6 @@
-- Irreversible: we collapsed multiple raw entity_type strings to
-- canonical forms and don't have a per-row record of the original.
-- The down migration is intentionally a no-op (the rewritten values
-- are still semantically correct), and the up migration is safe to
-- re-run because every UPDATE is conditional on `!= canonical`.
SELECT 1;
@@ -0,0 +1,43 @@
-- Canonicalize `entities.entity_type` so legacy rows from before
-- `normalize_entity_type` landed in upsert_entity stop polluting
-- client-side filters. Mirrors the synonym map in
-- `src/database/knowledge_dao.rs::normalize_entity_type`:
-- person ← person | people | human | individual | contact
-- place ← place | location | venue | site | area | landmark
-- event ← event | occasion | activity | celebration
-- thing ← thing | object | item | product
-- Types outside the synonym set (e.g. "friend", "family") are not
-- recognized as canonical and get a lowercase+trim pass instead, so
-- at minimum case variants collapse.
--
-- `UPDATE OR IGNORE` skips rows that would violate UNIQUE(name,
-- entity_type) after the rewrite. Two rows like ("Sarah", "person")
-- + ("Sarah", "Person") would otherwise collide — the duplicate
-- survives unchanged so the curator can merge it via the curation
-- UI rather than have the migration silently delete data.
UPDATE OR IGNORE entities
SET entity_type = 'person'
WHERE LOWER(TRIM(entity_type)) IN ('person', 'people', 'human', 'individual', 'contact')
AND entity_type != 'person';
UPDATE OR IGNORE entities
SET entity_type = 'place'
WHERE LOWER(TRIM(entity_type)) IN ('place', 'location', 'venue', 'site', 'area', 'landmark')
AND entity_type != 'place';
UPDATE OR IGNORE entities
SET entity_type = 'event'
WHERE LOWER(TRIM(entity_type)) IN ('event', 'occasion', 'activity', 'celebration')
AND entity_type != 'event';
UPDATE OR IGNORE entities
SET entity_type = 'thing'
WHERE LOWER(TRIM(entity_type)) IN ('thing', 'object', 'item', 'product')
AND entity_type != 'thing';
-- Anything left ("Friend" vs "friend") gets a lowercase+trim sweep
-- so at least case variants of the same custom type collapse.
UPDATE OR IGNORE entities
SET entity_type = LOWER(TRIM(entity_type))
WHERE entity_type != LOWER(TRIM(entity_type));
@@ -0,0 +1,5 @@
DROP INDEX IF EXISTS idx_image_exif_date_backfill;
CREATE INDEX idx_image_exif_date_backfill
ON image_exif (library_id, id)
WHERE date_taken IS NULL OR date_taken_source = 'fs_time';
@@ -0,0 +1,18 @@
-- Narrow the date-backfill partial index to NULL-only rows.
--
-- The original index (2026-05-06-000000_add_date_taken_source) also matched
-- `date_taken_source = 'fs_time'` so the drain could "re-resolve weak
-- entries when better tools become available." In practice the resolver
-- is deterministic on file bytes + filename + fs metadata: a row that
-- landed on fs_time once will land on fs_time again on every subsequent
-- tick. With `ORDER BY id ASC LIMIT 500`, the drain spun on the same
-- lowest-id fs_time rows in perpetuity, never advancing, while hammering
-- the SQLite write lock once per row and starving other writers (face
-- PATCHes were hitting busy_timeout and returning 500). Drop fs_time
-- from the eligibility set; if exiftool / a new filename pattern ever
-- comes online, a one-shot operator command can re-resolve.
DROP INDEX IF EXISTS idx_image_exif_date_backfill;
CREATE INDEX idx_image_exif_date_backfill
ON image_exif (library_id, id)
WHERE date_taken IS NULL;
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_image_exif_clip_backfill;
ALTER TABLE image_exif DROP COLUMN clip_model_version;
ALTER TABLE image_exif DROP COLUMN clip_embedding;
@@ -0,0 +1,27 @@
-- CLIP semantic photo search: store a per-photo image embedding so
-- text queries can rerank against the live library via cosine
-- similarity. Apollo encodes the bytes via its CLIP service; ImageApi
-- writes the resulting blob here.
--
-- `clip_embedding` is the raw little-endian float32 buffer of an
-- L2-normalized vector (dim depends on the model — 768 bytes×4 for
-- ViT-L/14, 512 bytes×4 for ViT-B/32). Apollo always returns the
-- normalized form so the search-time dot product reduces to a plain
-- cosine similarity.
--
-- `clip_model_version` echoes the upstream `APOLLO_CLIP_MODEL` (e.g.
-- "ViT-L/14"). A model swap shouldn't silently mix geometries — the
-- backfill drain will re-eligibilize rows whose stored model_version
-- differs from the live engine's, and the search route refuses to
-- mix rows from two model_versions in the same response.
ALTER TABLE image_exif ADD COLUMN clip_embedding BLOB;
ALTER TABLE image_exif ADD COLUMN clip_model_version TEXT;
-- Partial index for the backfill drain. Mirrors the shape of
-- `idx_image_exif_date_backfill`: candidate rows are those with a
-- known content_hash (so we don't race the unhashed backlog) but no
-- embedding yet. SELECT cost stays O(missing rows) instead of full
-- table scan once the column is mostly populated.
CREATE INDEX IF NOT EXISTS idx_image_exif_clip_backfill
ON image_exif (id)
WHERE clip_embedding IS NULL AND content_hash IS NOT NULL;
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_insight_gen_jobs_status_cleanup;
DROP INDEX IF EXISTS idx_insight_gen_jobs_file;
DROP TABLE IF EXISTS insight_generation_jobs;
@@ -0,0 +1,23 @@
-- Track async insight generation jobs so the client can poll for
-- completion after the server returns 202 Accepted. Each generation
-- creates a new row; the application layer cancels prior running
-- jobs before inserting.
CREATE TABLE insight_generation_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id INTEGER NOT NULL DEFAULT 1,
file_path TEXT NOT NULL,
generation_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'running',
started_at INTEGER NOT NULL,
completed_at INTEGER,
result_insight_id INTEGER,
error_message TEXT
);
-- For the status endpoint: fast lookup by (library_id, file_path)
CREATE INDEX idx_insight_gen_jobs_file
ON insight_generation_jobs(library_id, file_path);
-- For startup cleanup (future): prune old completed/failed jobs
CREATE INDEX idx_insight_gen_jobs_status_cleanup
ON insight_generation_jobs(status, started_at);
@@ -0,0 +1,28 @@
-- Restore UNIQUE constraint
CREATE TABLE insight_generation_jobs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id INTEGER NOT NULL DEFAULT 1,
file_path TEXT NOT NULL,
generation_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'running',
started_at INTEGER NOT NULL,
completed_at INTEGER,
result_insight_id INTEGER,
error_message TEXT,
UNIQUE(library_id, file_path, generation_type)
);
INSERT INTO insight_generation_jobs_new
SELECT id, library_id, file_path, generation_type, status, started_at, completed_at, result_insight_id, error_message
FROM insight_generation_jobs;
DROP TABLE insight_generation_jobs;
ALTER TABLE insight_generation_jobs_new RENAME TO insight_generation_jobs;
CREATE INDEX idx_insight_gen_jobs_file
ON insight_generation_jobs(library_id, file_path);
CREATE INDEX idx_insight_gen_jobs_status_cleanup
ON insight_generation_jobs(status, started_at);
@@ -0,0 +1,30 @@
-- Remove UNIQUE(library_id, file_path, generation_type) constraint to allow
-- multiple job rows per file. This enables proper cancel/regenerate semantics:
-- a new job is always inserted on regenerate, and the old job is cancelled
-- independently. The application layer prevents concurrent running jobs.
CREATE TABLE insight_generation_jobs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id INTEGER NOT NULL DEFAULT 1,
file_path TEXT NOT NULL,
generation_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'running',
started_at INTEGER NOT NULL,
completed_at INTEGER,
result_insight_id INTEGER,
error_message TEXT
);
INSERT INTO insight_generation_jobs_new
SELECT id, library_id, file_path, generation_type, status, started_at, completed_at, result_insight_id, error_message
FROM insight_generation_jobs;
DROP TABLE insight_generation_jobs;
ALTER TABLE insight_generation_jobs_new RENAME TO insight_generation_jobs;
CREATE INDEX idx_insight_gen_jobs_file
ON insight_generation_jobs(library_id, file_path);
CREATE INDEX idx_insight_gen_jobs_status_cleanup
ON insight_generation_jobs(status, started_at);
@@ -0,0 +1,11 @@
-- SQLite doesn't support DROP COLUMN before 3.35.0; recreate the table
-- without the new columns. This is only needed for rollback.
CREATE TABLE photo_insights_old AS
SELECT id, library_id, rel_path, title, summary, generated_at,
model_version, is_current, training_messages, approved,
backend, fewshot_source_ids, content_hash
FROM photo_insights;
DROP TABLE photo_insights;
ALTER TABLE photo_insights_old RENAME TO photo_insights;
@@ -0,0 +1,8 @@
-- Persist generation parameters on each insight row for auditing.
ALTER TABLE photo_insights ADD COLUMN num_ctx INTEGER;
ALTER TABLE photo_insights ADD COLUMN temperature REAL;
ALTER TABLE photo_insights ADD COLUMN top_p REAL;
ALTER TABLE photo_insights ADD COLUMN top_k INTEGER;
ALTER TABLE photo_insights ADD COLUMN min_p REAL;
ALTER TABLE photo_insights ADD COLUMN system_prompt TEXT;
ALTER TABLE photo_insights ADD COLUMN persona_id TEXT;
@@ -0,0 +1,13 @@
-- SQLite doesn't support DROP COLUMN before 3.35.0; recreate the table
-- without the token-count columns. This is only needed for rollback.
CREATE TABLE photo_insights_old AS
SELECT id, library_id, rel_path, title, summary, generated_at,
model_version, is_current, training_messages, approved,
backend, fewshot_source_ids, content_hash,
num_ctx, temperature, top_p, top_k, min_p,
system_prompt, persona_id
FROM photo_insights;
DROP TABLE photo_insights;
ALTER TABLE photo_insights_old RENAME TO photo_insights;
@@ -0,0 +1,6 @@
-- Persist token usage on each insight row. Split from
-- 2026-05-27-000002_add_insight_generation_params because that
-- migration was already applied on some environments before these
-- columns were added.
ALTER TABLE photo_insights ADD COLUMN prompt_eval_count INTEGER;
ALTER TABLE photo_insights ADD COLUMN eval_count INTEGER;
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_precomputed_reels_span_library;
DROP TABLE IF EXISTS precomputed_reels;
@@ -0,0 +1,14 @@
CREATE TABLE precomputed_reels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
span TEXT NOT NULL,
library_key TEXT NOT NULL,
cache_key TEXT NOT NULL,
output_path TEXT NOT NULL,
title TEXT NOT NULL,
media_count INT NOT NULL,
render_version INT NOT NULL DEFAULT 1,
tz_offset_minutes INT NOT NULL,
voice TEXT,
generated_at BIGINT NOT NULL
);
CREATE INDEX idx_precomputed_reels_span_library ON precomputed_reels(span, library_key, generated_at DESC);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS user_ai_prefs;
@@ -0,0 +1,7 @@
CREATE TABLE user_ai_prefs (
id INTEGER PRIMARY KEY CHECK(id=1),
voice TEXT,
tz_offset_minutes INTEGER,
library TEXT,
updated_at BIGINT NOT NULL
);
+392
View File
@@ -0,0 +1,392 @@
# Insight Chat improvements — design
**Date:** 2026-05-07
**Branch:** `feature/insight-chat-improvements` (in both `ImageApi/` and `FileViewer-React/`)
**Scope:** ImageApi photo-anchored insight + chat surface, plus the
FileViewer-React client. Apollo's free/visit chat is **not** in this cycle.
## Problem
Three concrete gaps in today's insight + chat surface:
1. **Tool drift.** ImageApi exposes 13 tools to the LLM. Some are gated on
`apollo_enabled` / `has_vision`, but several optional ones
(`search_rag`, `get_calendar_events`, `get_location_history`) are
registered unconditionally even when their backing tables are empty.
Descriptions vary in quality and a couple have outright bugs.
2. **Inconsistent / incomplete tool descriptions.** Tools like
`search_messages` describe their selection rules but omit useful
examples; `store_fact` doesn't show the `object_entity_id` vs
`object_value` choice; `get_sms_messages` accepts a `days_radius`
parameter that the backing client silently ignores. The LLM is being
instructed against a slightly wrong reality.
3. **System prompt fights the persona.** Today's generation prompt
prepends the user's `custom_system_prompt` and then immediately asserts
`"You are a personal photo memory assistant..."`. The user message
demands `"a detailed insight with a title and summary"`. Both
contradict whatever voice / shape / POV the persona just established.
On chat continuation the persona is baked into the stored transcript at
generation time and can't be changed without regenerating.
## Goals
- Tool catalog is **representative** — every tool registered for a turn is
backed by data the user actually has.
- Tool descriptions are **concise but complete**, with examples for any
tool whose param choice has multiple modes or non-obvious interactions.
- Persona / system prompt is **authoritative** for voice, length, and
shape — both at generation and during chat continuation.
- Per-turn system prompt overrides on chat work without surprising
side-effects on the stored transcript outside `amend` mode.
## Non-goals
- Apollo backend / frontend changes. Separate cycle.
- Refactoring the `generate_photo_title` post-hoc title flow. Already
takes `custom_system_prompt`.
- Tool consolidation (e.g. merging `search_messages` + `get_sms_messages`).
Considered and deferred — keeps blast radius small.
- Removing knowledge-memory tools (`recall_*` / `store_*`). Audit
confirmed they have a live read path via `knowledge.rs` HTTP routes.
- Persisting persona changes to the stored transcript outside `amend`
mode. Deliberate — re-opens use the persona currently active in the
client, not a sticky historical setting.
---
## Design
### A. System prompt — generation
Today (`insight_generator.rs:33053326`):
```
[custom_system_prompt if any] +
"You are a personal photo memory assistant helping to reconstruct..." +
{owner_id_note} +
{fewshot_block} +
"IMPORTANT INSTRUCTIONS:
1. You MUST call multiple tools...
2. When calling get_sms_messages and search_rag...
3. Use recall_facts_for_photo...
...
8. You have a hard budget of {max_iterations} iterations..."
```
The first concatenation is the bug: `custom` claims one identity, the
next line asserts another.
**New structure** — two named blocks, in order:
```
[Identity / voice / format block] ← persona-controlled (or neutral default)
[Procedural block] ← always identity-free
```
**Identity block:**
- When `custom_system_prompt` is supplied: use that string verbatim, no
pre/append.
- When not: a neutral default that doesn't fight a future persona.
Working text: `"You are reconstructing a memory from a photo. Use the
gathered context to write a thoughtful summary; you decide voice,
length, and shape."`
**Procedural block** — identity-free, always emitted:
```
Tool-use guidance:
- You have a budget of {max_iterations} tool-calling iterations.
- Call tools to gather context BEFORE writing your final answer; don't
answer after one or two calls.
- When calling get_sms_messages or search_rag, make at least one call
WITHOUT a contact filter — surrounding events matter even when a
contact is known.
- Use recall_facts_for_photo + recall_entities to load any prior
knowledge about subjects in the photo.
- When you identify people / places / events / things, use store_entity
+ store_fact to grow the persistent memory.
- A tool returning no results is informative; continue with the others.
{owner_id_note if applicable}
{fewshot_block if applicable}
```
Differences from today's "IMPORTANT INSTRUCTIONS" block: removed the
"you are a personal photo memory assistant" framing and the explicit
"at least 5 tool calls" floor (replaced with the softer "don't answer
after one or two"). Few-shot stays — it's pattern-of-tool-use, not
identity.
### B. User message — generation
Today (line 3357):
```
{visual_block}Please analyze this photo and gather any relevant context
from the surrounding weeks.
Photo file path: {file_path}
Date taken: {date}
{contact_info}
{gps_info}
{tags_info}
Use the available tools to gather more context about this moment
(messages, calendar events, location history, etc.), then write a
detailed insight with a title and summary.
```
Problems: the trailing line bakes in output shape ("title and
summary"), and the title from the resulting response is **discarded
anyway** — `generate_photo_title` (line 3494) regenerates the title
post-hoc from the summary. So the prompt is constraining voice for no
data-model benefit.
**New payload** — context-only, no output prescription:
```
{visual_block}Photo file path: {file_path}
Date taken: {date}
{contact_info}
{gps_info}
{tags_info}
Gather context with the available tools, then respond.
```
The persona owns shape. If a user wants "title-then-paragraph" output,
their persona prompt says so.
### C. System prompt — chat continuation
Add `system_prompt: Option<String>` to `ChatTurnRequest` (and to its
HTTP wrapper `ChatTurnHttpRequest`). It carries through both the
non-streaming `chat_turn` and the streaming `chat_turn_stream`.
**Append mode (default, `amend=false`)** — ephemeral
swap-and-restore, mirroring the existing `annotate_system_with_budget`
pattern:
1. Load stored transcript.
2. If `system_prompt` is `Some(s)`:
- If first message is a `system` role: stash original content,
replace with `s`.
- Else: prepend a synthetic ephemeral system message with `s` (note
it's synthetic so the restore step pops it rather than rewriting).
3. Run `annotate_system_with_budget` on top (existing per-turn budget
note appends to whatever's there now).
4. Run the agentic loop.
5. **Before persistence**, restore the original system content (or pop
the synthetic one). Run `restore_system_content` for the budget
annotation as today.
6. Save.
Result: the model sees the override; the stored transcript is
unchanged outside the model's actual reply.
**Amend mode (`amend=true`)**:
- If `system_prompt` is supplied: the override stays in place during
the serialization for the new insight row. The new row's
`training_messages` system message is the override. `is_current=false`
flips on prior rows as today.
- If not supplied: behaves as today (stored transcript's system message
carries forward unchanged).
### D. FileViewer-React — client wiring
`hooks/useInsightChat.tsx`:
- `SendTurnOptions` gains `systemPromptOverride?: string | null`.
- Inside `sendTurn`, before issuing the streaming POST:
1. Read the active persona's `systemPrompt` from AsyncStorage
(already loaded for generation flows — reuse the same accessor).
2. If a one-shot `systemPromptOverride` is set, append as a suffix
(`${persona}\n\n${override}`) so persona voice survives + override
tweaks the turn.
3. Include the resulting string as `system_prompt` on the request body.
- No history-load change. The history endpoint still returns the stored
transcript.
`components/InsightChatModal.tsx`:
- Add a small "Style note" composer affordance — a one-shot text input
that, when filled, becomes the `systemPromptOverride` for the next
send. Cleared after send.
- The existing persona chip continues to open `PersonaManagerModal`.
`hooks/usePersonas.tsx` and the bundled defaults:
- Built-in `assistant` and `journal` prompts get audited and rewritten
to **explicitly state voice / shape / length** — since the framework
no longer guarantees a default shape, the persona must.
### E. Tool catalog — gating
Widen `build_tool_definitions` from `(has_vision: bool, apollo_enabled:
bool)` to a single `ToolGateOpts` struct:
```rust
pub struct ToolGateOpts {
pub has_vision: bool,
pub apollo_enabled: bool,
pub daily_summaries_present: bool,
pub calendar_present: bool,
pub location_history_present: bool,
}
```
The chat / generation services compute the three new fields lazily per
turn via `SELECT 1 FROM <table> LIMIT 1` (cheap; cached for the turn's
duration). Lazy because operators import data after launch and we don't
want to require a restart for the LLM to discover its new capabilities.
Per-tool gating:
| Tool | Existing gate | New gate |
|---|---|---|
| `describe_photo` | `has_vision` | unchanged |
| `get_personal_place_at` | `apollo_enabled` | unchanged |
| `get_calendar_events` | none | `calendar_present` |
| `get_location_history` | none | `location_history_present` |
| `search_rag` | none | `daily_summaries_present` |
All other tools always-on. (`get_sms_messages` and `search_messages`
fail informatively if SMS-API is unreachable; not worth a startup probe
since intermittent failures are the same shape.)
### F. Tool descriptions — convention
Every description follows:
1. One sentence: **what** + **when to call**.
2. Param semantics worth knowing (units, ranges, mode behavior,
precedence).
3. **Example invocation** for tools with multiple modes, optional bands,
or non-obvious parameter interactions.
4. Cross-references when relevant: `prefer X when both apply`.
Banned: all-caps section headers inside descriptions
(`"CONTENT search"`, `"TIME-BASED fetch"`); persona-prescriptive language
(`"you are a..."`); behavioral references to other tools by description
rather than name.
Tools getting examples: `search_messages`, `search_rag`, `store_fact`,
`get_sms_messages`. Trivial tools (`get_current_datetime`,
`reverse_geocode`, `get_file_tags`) skip the example.
Sample (`search_messages`):
> Search SMS/MMS message bodies. Modes: `fts5` (keyword + phrase + prefix
> + AND/OR/NOT + NEAR proximity), `semantic` (embedding similarity,
> requires generated embeddings), `hybrid` (RRF merge, recommended;
> degrades to `fts5` when embeddings absent). Optional `start_ts` /
> `end_ts` (real-UTC unix seconds) and `contact_id` filters. For pure
> date / contact browsing without keywords, prefer `get_sms_messages`.
>
> Examples:
> - `{query: "trader joe's"}` — phrase across all time.
> - `{query: "dinner", contact_id: 42, start_ts: 1700000000, end_ts: 1700604800}`
> — keyword within a contact and a week.
> - `{query: "NEAR(meeting work, 5)"}` — proximity search.
### G. SMS tool fixes
#### `get_sms_messages` — honor `days_radius`
Today: `sms_client::fetch_messages_for_contact(contact, center_ts)`
hardcodes `Duration::days(4)` (lines 3137). The tool accepts
`days_radius` and silently ignores it.
**Fix:** widen the signature to
`fetch_messages_for_contact(contact, center_ts, days_radius)`. Tool
plumbs through. Default 4 retained for back-compat.
#### `search_messages` — add date and contact_id filters
Today: ImageApi's `search_messages` only forwards `query`, `mode`,
`limit` to SMS-API.
**Fix:** add `start_ts`, `end_ts`, `contact_id` parameters.
- `contact_id` forwards directly to SMS-API
(`/api/messages/search/?contact_id=`).
- `start_ts` / `end_ts` are not natively accepted by SMS-API's search
endpoint. Apply client-side post-filter on the response (Apollo's
pattern: `chat_tools.py:670680`). Bump the SMS-API `limit` to a
larger fetch pool when a date filter is supplied so in-window matches
aren't lost to out-of-window FTS rank.
---
## Implementation sequencing
Each step is independently mergeable.
### ImageApi PRs
1. **Split system-prompt assembly + neutralize user message.** Two
named blocks; user message context-only. Default identity string
added. Tests: golden snapshots of the resulting `system_content`
with and without `custom_system_prompt`.
2. **`system_prompt` field on chat request + swap/restore + amend
persistence.** Mirrors `annotate_system_with_budget` pattern. Tests:
round-trip system content unchanged in append mode; persisted in
amend mode.
3. **`fetch_messages_for_contact` honors `days_radius`.** Tool wires
the param through. Tests: window math at the client level.
4. **`ToolGateOpts` + per-tool description rewrites.** Description
text changes are the bulk of the diff but no behavior change beyond
gating.
### FileViewer-React PR
5. **Chat hook sends `system_prompt`; modal gets style-note input;
built-in personas updated to specify shape.** The
`useInsightChat.sendTurn` call site picks up the persona and
includes it on every chat turn body. Style-note input is a one-shot
suffix.
## Testing & verification
**Automated:**
- Unit (Rust): swap-and-restore round-trip preserves stored transcript.
- Unit (Rust): amend mode persists override into new insight row.
- Unit (Rust): `fetch_messages_for_contact(days_radius=N)` produces a
window of `2N` days centered on `center_ts`.
- Unit (Rust): `build_tool_definitions(opts)` excludes gated tools when
the corresponding flag is false.
**Manual:**
- Run a chat turn against an existing insight without `system_prompt`
output unchanged from baseline.
- Same insight, with override → output reflects new voice.
- Re-open chat → original baked persona still authoritative (override
was ephemeral).
- Regenerate an insight with the journal persona → model's voice
matches journal style; no "memory assistant" framing leaks through.
- Toggle data presence (delete a row from `calendar_events`) → tool
drops from the catalog on the next turn.
## Risks
- **Default identity wording matters.** A too-neutral default ("Use the
gathered context to write a summary") might produce flatter output
than today's "personal photo memory assistant" framing for users
who never set a persona. Mitigation: tune the default with a small
set of test photos before merging.
- **Persona-suffix style notes can contradict persona voice.** A user
who picks `journal` (first person, warm) and adds the style note
"respond in bullet points" will get a tonal collision. Acceptable —
the user expressed a per-turn intent and we honor it. Document the
composition rule in the persona-manager UI.
- **Lazy data-presence probes add a per-turn `SELECT 1`.** Negligible
on SQLite (sub-millisecond) but adds up across many turns. Cache the
result for the turn's duration; don't re-probe per-tool.
## Open questions
None blocking. Items deferred to a possible follow-up cycle:
- Apollo parity for the same per-turn override pattern (already
present; just needs RN client wiring on the photo path which is
already proxy).
- Tool consolidation (`search_messages` + `get_sms_messages`
single `search_messages` with optional date filter, Apollo-style).
Considered and deferred — separate spec.
+146
View File
@@ -0,0 +1,146 @@
use anyhow::{Result, anyhow};
use crate::ai::llm_client::LlmClient;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BackendKind {
Local,
Hybrid,
}
impl BackendKind {
pub fn parse(s: &str) -> Result<Self> {
match s.trim().to_lowercase().as_str() {
"local" | "" => Ok(Self::Local),
"hybrid" => Ok(Self::Hybrid),
other => Err(anyhow!(
"unknown backend '{}'; expected 'local' or 'hybrid'",
other
)),
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Local => "local",
Self::Hybrid => "hybrid",
}
}
}
impl std::fmt::Display for BackendKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
pub struct SamplingOverrides {
pub model: Option<String>,
pub num_ctx: Option<i32>,
pub temperature: Option<f32>,
pub top_p: Option<f32>,
pub top_k: Option<i32>,
pub min_p: Option<f32>,
/// Reasoning toggle. Only the llama.cpp backend honors it (forwarded as
/// `chat_template_kwargs.enable_thinking`); other backends ignore it.
/// `None` leaves the model/template default in place.
pub enable_thinking: Option<bool>,
}
impl SamplingOverrides {
pub fn has_sampling(&self) -> bool {
self.temperature.is_some()
|| self.top_p.is_some()
|| self.top_k.is_some()
|| self.min_p.is_some()
}
}
pub struct ResolvedBackend {
chat: Box<dyn LlmClient>,
local: Box<dyn LlmClient>,
pub kind: BackendKind,
/// `true` when the chat model receives images directly (Ollama with
/// vision, or llamacpp). `false` for hybrid where we describe-then-inline.
pub images_inline: bool,
}
impl ResolvedBackend {
pub fn new(
chat: Box<dyn LlmClient>,
local: Box<dyn LlmClient>,
kind: BackendKind,
images_inline: bool,
) -> Self {
Self {
chat,
local,
kind,
images_inline,
}
}
pub fn chat(&self) -> &dyn LlmClient {
self.chat.as_ref()
}
pub fn local(&self) -> &dyn LlmClient {
self.local.as_ref()
}
pub fn model(&self) -> &str {
self.chat.primary_model()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_backend_kind() {
assert_eq!(BackendKind::parse("local").unwrap(), BackendKind::Local);
assert_eq!(BackendKind::parse("hybrid").unwrap(), BackendKind::Hybrid);
assert_eq!(BackendKind::parse(" Local ").unwrap(), BackendKind::Local);
assert_eq!(BackendKind::parse("HYBRID").unwrap(), BackendKind::Hybrid);
assert_eq!(BackendKind::parse("").unwrap(), BackendKind::Local);
assert!(BackendKind::parse("vllm").is_err());
}
#[test]
fn backend_kind_as_str_roundtrips() {
assert_eq!(
BackendKind::parse(BackendKind::Local.as_str()).unwrap(),
BackendKind::Local
);
assert_eq!(
BackendKind::parse(BackendKind::Hybrid.as_str()).unwrap(),
BackendKind::Hybrid
);
}
#[test]
fn sampling_overrides_has_sampling() {
let empty = SamplingOverrides {
model: None,
num_ctx: None,
temperature: None,
top_p: None,
top_k: None,
min_p: None,
enable_thinking: None,
};
assert!(!empty.has_sampling());
let with_temp = SamplingOverrides {
model: None,
num_ctx: Some(4096),
temperature: Some(0.7),
top_p: None,
top_k: None,
min_p: None,
enable_thinking: None,
};
assert!(with_temp.has_sampling());
}
}
+395
View File
@@ -0,0 +1,395 @@
//! Thin async HTTP client for Apollo's `/api/internal/clip/*` endpoints.
//!
//! Apollo hosts the OpenAI CLIP inference service (ViT-L/14 by default,
//! configurable via `APOLLO_CLIP_MODEL`). This client is the ImageApi side
//! of the contract: shove image bytes through `/encode_image` to populate
//! `image_exif.clip_embedding` during backfill, and call `/encode_text` to
//! encode a user's natural-language query at search time. The actual
//! cosine-similarity rerank runs locally in ImageApi.
//!
//! Mirrors `face_client.rs` / `tag_client.rs` shape: optional base URL
//! (None = disabled — feature off, drain and search no-op), reqwest
//! client with a generous timeout because GPU inference under a backlog
//! can queue server-side (Apollo's threadpool is bounded to 1 worker on
//! CUDA).
//!
//! Configured via `APOLLO_CLIP_API_BASE_URL`, falling back to
//! `APOLLO_API_BASE_URL` when the dedicated var is unset (single-Apollo
//! deploys are the common case).
//!
//! Wire format:
//! - `/encode_image`: multipart/form-data with `file=<bytes>` and
//! `meta=<json>` (content_hash / library_id / rel_path for logging).
//! - `/encode_text`: JSON `{"text": "<query>"}`.
//!
//! Both return `{model_version, embedding_dim, duration_ms, embedding}`
//! where `embedding` is base64 of `dim×4` little-endian float32 bytes,
//! L2-normalized so the rerank reduces to a plain dot product.
//!
//! Error mapping (reflected in [`ClipError`]):
//! - 422 `decode_failed` / `empty_text` → permanent: ImageApi marks the
//! row failed or surfaces the empty-query error to the search caller.
//! - 503 `cuda_oom` / `engine_unavailable` → defer-and-retry: no marker.
//! - Any other 5xx / network error → defer.
use anyhow::{Context, Result};
use base64::Engine;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Debug, Clone, Serialize)]
pub struct EncodeImageMeta {
pub content_hash: String,
pub library_id: i32,
pub rel_path: String,
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)] // duration_ms logged by the backfill drain
pub struct EncodeResponse {
pub model_version: String,
pub embedding_dim: i32,
pub duration_ms: i64,
/// base64 of `embedding_dim * 4` bytes (LE float32). ImageApi stores
/// the decoded bytes verbatim as a BLOB.
pub embedding: String,
}
impl EncodeResponse {
/// Decode the wire-format embedding back into raw bytes for storage.
/// Validates the buffer is `embedding_dim * 4` bytes long so a
/// malformed response surfaces here rather than as a downstream
/// silent length mismatch.
pub fn decode_embedding(&self) -> Result<Vec<u8>> {
let bytes = base64::engine::general_purpose::STANDARD
.decode(self.embedding.as_bytes())
.context("clip embedding base64 decode")?;
let expected = (self.embedding_dim as usize) * 4;
if bytes.len() != expected {
anyhow::bail!(
"clip embedding wrong size: got {} bytes, expected {} ({} * 4)",
bytes.len(),
expected,
self.embedding_dim
);
}
Ok(bytes)
}
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)] // load_error consumed by future health probe
pub struct ClipHealth {
pub loaded: bool,
pub device: String,
pub model_version: String,
pub embedding_dim: i32,
#[serde(default)]
pub load_error: Option<String>,
}
#[derive(Debug)]
pub enum ClipError {
/// Apollo refused for a reason that won't change on retry (decode
/// failure on /encode_image, empty text on /encode_text).
Permanent(anyhow::Error),
/// Apollo couldn't process this turn but might next time (CUDA OOM,
/// engine not loaded, network hiccup).
Transient(anyhow::Error),
/// Feature is disabled (no `APOLLO_CLIP_API_BASE_URL` /
/// `APOLLO_API_BASE_URL`).
Disabled,
}
impl std::fmt::Display for ClipError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ClipError::Permanent(e) => write!(f, "permanent: {e}"),
ClipError::Transient(e) => write!(f, "transient: {e}"),
ClipError::Disabled => write!(f, "clip client disabled"),
}
}
}
impl std::error::Error for ClipError {}
#[derive(Clone)]
pub struct ClipClient {
client: Client,
base_url: Option<String>,
}
impl ClipClient {
pub fn new(base_url: Option<String>) -> Self {
let timeout_secs = std::env::var("CLIP_REQUEST_TIMEOUT_SEC")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(60);
let client = Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.expect("reqwest client build");
Self {
client,
base_url: base_url.map(|u| u.trim_end_matches('/').to_string()),
}
}
/// Read both standard env vars. `APOLLO_CLIP_API_BASE_URL` wins;
/// fallback to `APOLLO_API_BASE_URL`. Both unset → disabled.
pub fn from_env() -> Self {
let base = std::env::var("APOLLO_CLIP_API_BASE_URL")
.ok()
.filter(|s| !s.trim().is_empty())
.or_else(|| {
std::env::var("APOLLO_API_BASE_URL")
.ok()
.filter(|s| !s.trim().is_empty())
});
Self::new(base)
}
pub fn is_enabled(&self) -> bool {
self.base_url.is_some()
}
/// Encode an image to a 768-d (ViT-L/14) or 512-d (ViT-B/32)
/// L2-normalized embedding. Used by the backfill drain.
pub async fn encode_image(
&self,
bytes: Vec<u8>,
meta: EncodeImageMeta,
) -> std::result::Result<EncodeResponse, ClipError> {
let Some(base) = self.base_url.as_deref() else {
return Err(ClipError::Disabled);
};
let url = format!("{}/api/internal/clip/encode_image", base);
let meta_json = serde_json::to_string(&meta)
.map_err(|e| ClipError::Permanent(anyhow::anyhow!("meta serialize: {e}")))?;
let form = reqwest::multipart::Form::new()
.text("meta", meta_json)
.part(
"file",
reqwest::multipart::Part::bytes(bytes)
.file_name(meta.rel_path.clone())
.mime_str("application/octet-stream")
.unwrap_or_else(|_| reqwest::multipart::Part::bytes(Vec::new())),
);
self.send_multipart(&url, form).await
}
/// Encode a natural-language query to an embedding. Used by the
/// search route to rank stored image embeddings by cosine sim.
pub async fn encode_text(&self, text: &str) -> std::result::Result<EncodeResponse, ClipError> {
let Some(base) = self.base_url.as_deref() else {
return Err(ClipError::Disabled);
};
let url = format!("{}/api/internal/clip/encode_text", base);
let body = serde_json::json!({ "text": text });
let resp = match self.client.post(&url).json(&body).send().await {
Ok(r) => r,
Err(e) if e.is_timeout() || e.is_connect() => {
log::warn!("clip encode_text network error to {url}: {e}");
return Err(ClipError::Transient(anyhow::anyhow!(
"clip client network: {e}"
)));
}
Err(e) => {
log::warn!("clip encode_text request error to {url}: {e}");
return Err(ClipError::Transient(anyhow::anyhow!(
"clip client request: {e}"
)));
}
};
let status = resp.status();
if status.is_success() {
let body: EncodeResponse = resp
.json()
.await
.map_err(|e| ClipError::Transient(anyhow::anyhow!("clip response decode: {e}")))?;
return Ok(body);
}
let body_text = resp.text().await.unwrap_or_default();
log::warn!("clip encode_text HTTP {status} from {url}: {body_text}");
Err(classify_error_response(status.as_u16(), &body_text))
}
/// Engine reachability + device/model report. Used as a startup
/// sanity check from the probe binary and (later) the backlog drain.
#[allow(dead_code)] // consumed by probe + drain
pub async fn health(&self) -> Result<ClipHealth> {
let base = self.base_url.as_deref().context("clip client disabled")?;
let url = format!("{}/api/internal/clip/health", base);
let resp = self.client.get(&url).send().await?.error_for_status()?;
let body: ClipHealth = resp.json().await?;
Ok(body)
}
async fn send_multipart(
&self,
url: &str,
form: reqwest::multipart::Form,
) -> std::result::Result<EncodeResponse, ClipError> {
let resp = match self.client.post(url).multipart(form).send().await {
Ok(r) => r,
Err(e) if e.is_timeout() || e.is_connect() => {
return Err(ClipError::Transient(anyhow::anyhow!(
"clip client network: {e}"
)));
}
Err(e) => {
return Err(ClipError::Transient(anyhow::anyhow!(
"clip client request: {e}"
)));
}
};
let status = resp.status();
if status.is_success() {
let body: EncodeResponse = resp
.json()
.await
.map_err(|e| ClipError::Transient(anyhow::anyhow!("clip response decode: {e}")))?;
return Ok(body);
}
let body_text = resp.text().await.unwrap_or_default();
Err(classify_error_response(status.as_u16(), &body_text))
}
}
/// Pulled out as a pure function so the marker-row contract is unit-
/// testable without spinning up an HTTP server. Matches the shape used
/// by face_client::classify_error_response so future retry policies
/// can share code.
fn classify_error_response(status: u16, body_text: &str) -> ClipError {
let detail_code = serde_json::from_str::<serde_json::Value>(body_text)
.ok()
.and_then(|v| {
v.get("detail")
.and_then(|d| d.as_str().map(str::to_string))
.or_else(|| {
v.get("detail")
.and_then(|d| d.get("code"))
.and_then(|c| c.as_str())
.map(str::to_string)
})
})
.unwrap_or_default();
if status == 422 {
return ClipError::Permanent(anyhow::anyhow!(
"clip {} {}: {}",
status,
detail_code,
body_text
));
}
if status == 503 {
return ClipError::Transient(anyhow::anyhow!(
"clip {} {}: {}",
status,
detail_code,
body_text
));
}
// 408 / 413 / 429 are operator-fixable infra issues; defer.
if matches!(status, 408 | 413 | 429) {
return ClipError::Transient(anyhow::anyhow!(
"clip {} {}: {}",
status,
detail_code,
body_text
));
}
if (400..500).contains(&status) {
ClipError::Permanent(anyhow::anyhow!(
"clip {} {}: {}",
status,
detail_code,
body_text
))
} else {
ClipError::Transient(anyhow::anyhow!(
"clip {} {}: {}",
status,
detail_code,
body_text
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn is_permanent(e: &ClipError) -> bool {
matches!(e, ClipError::Permanent(_))
}
fn is_transient(e: &ClipError) -> bool {
matches!(e, ClipError::Transient(_))
}
#[test]
fn classify_422_decode_failed_is_permanent() {
assert!(is_permanent(&classify_error_response(
422,
r#"{"detail":"decode_failed: bad bytes"}"#
)));
}
#[test]
fn classify_422_empty_text_is_permanent() {
assert!(is_permanent(&classify_error_response(
422,
r#"{"detail":"empty_text"}"#
)));
}
#[test]
fn classify_503_cuda_oom_is_transient() {
assert!(is_transient(&classify_error_response(
503,
r#"{"detail":{"code":"cuda_oom","error":"out of memory"}}"#,
)));
}
#[test]
fn classify_5xx_is_transient_other_4xx_is_permanent() {
assert!(is_transient(&classify_error_response(500, "")));
assert!(is_permanent(&classify_error_response(404, "{}")));
}
#[test]
fn classify_infra_4xx_is_transient() {
assert!(is_transient(&classify_error_response(408, "")));
assert!(is_transient(&classify_error_response(413, "<html>")));
assert!(is_transient(&classify_error_response(429, "{}")));
}
#[test]
fn decode_embedding_size_mismatch_errors() {
// dim=4 says we expect 16 bytes (4 floats × 4 bytes). Encode 8.
use base64::Engine;
let resp = EncodeResponse {
model_version: "ViT-L/14".into(),
embedding_dim: 4,
duration_ms: 0,
embedding: base64::engine::general_purpose::STANDARD.encode([0u8; 8]),
};
assert!(resp.decode_embedding().is_err());
}
#[test]
fn decode_embedding_round_trip() {
use base64::Engine;
let bytes: Vec<u8> = (0..16).collect();
let resp = EncodeResponse {
model_version: "ViT-L/14".into(),
embedding_dim: 4,
duration_ms: 0,
embedding: base64::engine::general_purpose::STANDARD.encode(&bytes),
};
assert_eq!(resp.decode_embedding().unwrap(), bytes);
}
}
+4 -1
View File
@@ -383,7 +383,10 @@ mod tests {
// body cap and rejected normal-size photos before they reached // body cap and rejected normal-size photos before they reached
// the backend. // the backend.
assert!(is_transient(&classify_error_response(408, ""))); assert!(is_transient(&classify_error_response(408, "")));
assert!(is_transient(&classify_error_response(413, "<html>nginx</html>"))); assert!(is_transient(&classify_error_response(
413,
"<html>nginx</html>"
)));
assert!(is_transient(&classify_error_response(429, "{}"))); assert!(is_transient(&classify_error_response(429, "{}")));
} }
+88
View File
@@ -0,0 +1,88 @@
// GPU lease — in-process coordination for llama-swap model contention.
//
// llama-swap runs the heavyweight models (chat / vision / Chatterbox TTS) as
// a mutually-exclusive set on one GPU (matrix DSL `(q27 | … | tts) & e`): a
// request for a non-resident model is HELD by llama-swap until the resident
// model's in-flight requests drain, then the models swap. That hold counts
// against the *holder's* reqwest timeout — measured live: a queued TTS burned
// 77s of its budget behind a single LLM turn, and an LLM request behind a
// running synthesis waited the entire remaining synth. Uncoordinated
// cross-model traffic therefore times out instead of queueing.
//
// The lease moves that wait into this process, BEFORE the HTTP request is
// sent and before its timeout starts:
// - chat/vision requests (the LLM-side slots) share the READ lease;
// - TTS synthesis and voice-library ops (anything that spins Chatterbox up
// and evicts the LLM) take the WRITE lease;
// - embeddings take NO lease: the `embed` slot is in llama-swap's
// always-resident group (the `& e` term) and never participates in a swap,
// so leasing it would only stall searches behind a queued synthesis.
//
// tokio's RwLock is fair (FIFO, write-preferring): a queued TTS gets the GPU
// right after the current LLM request drains, and later LLM requests queue
// behind it — bounded waits in both directions, no starvation, no timeout
// budget burned while waiting.
//
// RULES: hold a lease for exactly one HTTP request (for streaming, the
// stream's lifetime) and NEVER acquire one while already holding one — once a
// writer is queued, new read acquisitions block, so nested acquisition can
// deadlock.
use std::sync::LazyLock;
use std::time::Instant;
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
static GPU_LEASE: LazyLock<RwLock<()>> = LazyLock::new(|| RwLock::new(()));
/// Waits longer than this are logged — they mean a cross-model swap was
/// avoided and quantify what the request *would* have burned of its timeout.
const SLOW_WAIT_LOG_SECS: f64 = 2.0;
/// Shared lease for LLM-side requests (chat / vision slots).
pub async fn llm_lease() -> RwLockReadGuard<'static, ()> {
let started = Instant::now();
let guard = GPU_LEASE.read().await;
log_slow_wait("llm", started);
guard
}
/// Exclusive lease for TTS-side requests (speech synthesis + voice-library
/// ops that spin up Chatterbox).
pub async fn tts_lease() -> RwLockWriteGuard<'static, ()> {
let started = Instant::now();
let guard = GPU_LEASE.write().await;
log_slow_wait("tts", started);
guard
}
fn log_slow_wait(kind: &str, started: Instant) {
let waited = started.elapsed().as_secs_f64();
if waited > SLOW_WAIT_LOG_SECS {
log::info!("GPU lease ({kind}): waited {waited:.1}s for the other model class to drain");
}
}
#[cfg(test)]
mod tests {
use super::*;
// One sequential test, not several: the lease is a single global, so
// parallel tests interleaving reads and writes on it can hit the very
// nested-acquisition deadlock the module comment warns about.
#[tokio::test]
async fn write_lease_excludes_readers_then_reads_share() {
let w = tts_lease().await;
// A reader must not acquire while the writer is held.
let pending = tokio::spawn(async { drop(llm_lease().await) });
tokio::task::yield_now().await;
assert!(!pending.is_finished());
drop(w);
pending.await.expect("reader acquires after writer drops");
// With no writer queued, read leases are shared.
let a = llm_lease().await;
let b = llm_lease().await;
drop(a);
drop(b);
}
}
+1260 -164
View File
File diff suppressed because it is too large Load Diff
+1883 -274
View File
File diff suppressed because it is too large Load Diff
+1911 -547
View File
File diff suppressed because it is too large Load Diff
+1444
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -170,3 +170,55 @@ pub struct ModelCapabilities {
pub has_vision: bool, pub has_vision: bool,
pub has_tool_calling: bool, pub has_tool_calling: bool,
} }
/// Strip a leading `<think>…</think>` reasoning block from model output.
///
/// Thinking models sometimes emit chain-of-thought inside think tags before
/// the real answer. Everything after the first `</think>` is the answer;
/// when no tag is present — or the text after it is empty — the trimmed
/// input is returned unchanged. Mirrors the behavior Ollama's
/// `extract_final_answer` has applied to single-shot generation; shared here
/// so the tool-calling final-content paths (agentic generation + chat) can
/// apply the identical cleanup before parsing / persisting.
pub fn strip_think_blocks(response: &str) -> String {
let response = response.trim();
if let Some(pos) = response.find("</think>") {
let answer = response[pos + "</think>".len()..].trim();
if !answer.is_empty() {
return answer.to_string();
}
}
response.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_think_blocks_removes_leading_think_block() {
let raw = "<think>\nLet me reason about this.\n</think>\n\nTitle: A Day Out\n\nThe body.";
assert_eq!(strip_think_blocks(raw), "Title: A Day Out\n\nThe body.");
}
#[test]
fn strip_think_blocks_passes_through_plain_content() {
assert_eq!(strip_think_blocks(" just an answer "), "just an answer");
}
#[test]
fn strip_think_blocks_keeps_content_when_answer_after_tag_is_empty() {
// A think block with nothing after it: better to return the trimmed
// original than an empty string (matches Ollama's fallback).
let raw = "<think>only thoughts</think>";
assert_eq!(strip_think_blocks(raw), raw);
}
#[test]
fn strip_think_blocks_handles_unclosed_tag() {
let raw = "<think>thinking forever";
assert_eq!(strip_think_blocks(raw), raw);
}
}
+88
View File
@@ -0,0 +1,88 @@
//! Bundle of the local LLM pair (Ollama + optional llama-swap) with the
//! `LLM_BACKEND` dispatch baked in.
//!
//! Exists because passing the pair around as loose values invited the same
//! bug three times: import/backfill tooling embedded corpora via
//! `OllamaClient` directly while the query side dispatched through
//! `embed_one`, so flipping `LLM_BACKEND=llamacpp` silently split queries
//! and corpus into different vector spaces. Anything that writes or reads
//! embeddings should go through this type (or `embed_one`/`embed_many`),
//! never a concrete client.
//!
//! Deliberately knows nothing about chat policy — hybrid/OpenRouter routing
//! is request-scoped and stays in `ResolvedBackend`. This is only the
//! local stack: embeddings and offline single-shot generation.
// Constructed by binaries, not the server — dead code from main.rs's view.
#![allow(dead_code)]
use std::sync::Arc;
use anyhow::Result;
use super::llamacpp::LlamaCppClient;
use super::llm_client::LlmClient;
use super::ollama::{EMBEDDING_MODEL, OllamaClient};
#[derive(Clone)]
pub struct LocalLlm {
ollama: OllamaClient,
llamacpp: Option<Arc<LlamaCppClient>>,
}
impl LocalLlm {
pub fn new(ollama: OllamaClient, llamacpp: Option<Arc<LlamaCppClient>>) -> Self {
Self { ollama, llamacpp }
}
/// Construct from the canonical env wiring shared with `AppState`.
pub fn from_env() -> Self {
Self::new(
crate::state::build_ollama_from_env(),
crate::state::build_llamacpp_from_env(),
)
}
/// Embed a search query (applies `EMBED_QUERY_PREFIX`). Callers must
/// pick query vs document — retrieval models treat the two sides
/// differently and an unmarked embed invites prefix-mismatch bugs.
pub async fn embed_query(&self, text: &str) -> Result<Vec<f32>> {
super::embed_query(&self.ollama, self.llamacpp.as_deref(), text).await
}
/// Embed corpus text (applies `EMBED_DOCUMENT_PREFIX`).
pub async fn embed_document(&self, text: &str) -> Result<Vec<f32>> {
super::embed_document(&self.ollama, self.llamacpp.as_deref(), text).await
}
/// Single-shot local text generation via the `LLM_BACKEND`-selected
/// client (offline tooling; chat turns belong to `ResolvedBackend`).
pub async fn generate(&self, prompt: &str, system: Option<&str>) -> Result<String> {
if super::local_backend_is_llamacpp() {
if let Some(lc) = self.llamacpp.as_deref() {
return <LlamaCppClient as LlmClient>::generate(lc, prompt, system, None).await;
}
anyhow::bail!(
"LLM_BACKEND=llamacpp but LlamaCppClient is unconfigured — \
set LLAMA_SWAP_URL or switch to LLM_BACKEND=ollama"
);
}
self.ollama.generate(prompt, system).await
}
/// Label identifying which backend + model produces embeddings right
/// now. Store it alongside vectors (`model_version` columns) so a
/// backend flip is detectable in the data, not just in env history.
pub fn embedding_model_version(&self) -> String {
if super::local_backend_is_llamacpp() {
let slot = self
.llamacpp
.as_deref()
.map(|c| c.embedding_model.as_str())
.unwrap_or("embed");
format!("llama-swap:{}", slot)
} else {
EMBEDDING_MODEL.to_string()
}
}
}
+174 -4
View File
@@ -1,13 +1,22 @@
pub mod apollo_client; pub mod apollo_client;
pub mod backend;
pub mod clip_client;
pub mod daily_summary_job; pub mod daily_summary_job;
pub mod face_client; pub mod face_client;
pub mod gpu;
pub mod handlers; pub mod handlers;
pub mod insight_chat; pub mod insight_chat;
pub mod insight_generator; pub mod insight_generator;
pub mod llamacpp;
pub mod llm_client; pub mod llm_client;
pub mod local_llm;
pub mod nl_query;
pub mod ollama; pub mod ollama;
pub mod openrouter; pub mod openrouter;
pub mod pronunciation;
pub mod sms_client; pub mod sms_client;
pub mod tts;
pub mod turn_registry;
// strip_summary_boilerplate is used by binaries (test_daily_summary), not the library // strip_summary_boilerplate is used by binaries (test_daily_summary), not the library
#[allow(unused_imports)] #[allow(unused_imports)]
@@ -16,18 +25,29 @@ pub use daily_summary_job::{
generate_daily_summaries, strip_summary_boilerplate, generate_daily_summaries, strip_summary_boilerplate,
}; };
pub use handlers::{ pub use handlers::{
chat_history_handler, chat_rewind_handler, chat_stream_handler, chat_turn_handler, cancel_generation_handler, cancel_turn_handler, chat_history_handler, chat_rewind_handler,
delete_insight_handler, export_training_data_handler, generate_agentic_insight_handler, chat_stream_handler, chat_turn_handler, delete_insight_handler, export_training_data_handler,
generate_insight_handler, get_all_insights_handler, get_available_models_handler, generate_agentic_insight_handler, generate_insight_handler, generation_status_handler,
get_insight_handler, get_openrouter_models_handler, rate_insight_handler, get_all_insights_handler, get_available_models_handler, get_insight_handler,
get_insight_history_handler, get_openrouter_models_handler, rate_insight_handler,
turn_async_handler, turn_replay_handler,
}; };
pub use insight_generator::InsightGenerator; pub use insight_generator::InsightGenerator;
pub use llamacpp::LlamaCppClient;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use llm_client::{ pub use llm_client::{
ChatMessage, LlmClient, ModelCapabilities, Tool, ToolCall, ToolCallFunction, ToolFunction, ChatMessage, LlmClient, ModelCapabilities, Tool, ToolCall, ToolCallFunction, ToolFunction,
}; };
// LocalLlm is constructed by binaries (reembed_embeddings, importers), not the server
#[allow(unused_imports)]
pub use local_llm::LocalLlm;
pub use ollama::{EMBEDDING_MODEL, OllamaClient}; pub use ollama::{EMBEDDING_MODEL, OllamaClient};
pub use sms_client::{SmsApiClient, SmsMessage}; pub use sms_client::{SmsApiClient, SmsMessage};
pub use tts::{
cancel_speech_job_handler, create_speech_job_handler, create_voice_from_library_handler,
create_voice_upload_handler, delete_voice_handler, list_voices_handler,
speech_job_status_handler, tts_speech_handler,
};
/// Display name used for the user in message transcripts and first-person /// Display name used for the user in message transcripts and first-person
/// prompt text. Reads the `USER_NAME` env var; defaults to `"Me"`. Models /// prompt text. Reads the `USER_NAME` env var; defaults to `"Me"`. Models
@@ -37,3 +57,153 @@ pub use sms_client::{SmsApiClient, SmsMessage};
pub fn user_display_name() -> String { pub fn user_display_name() -> String {
std::env::var("USER_NAME").unwrap_or_else(|_| "Me".to_string()) std::env::var("USER_NAME").unwrap_or_else(|_| "Me".to_string())
} }
/// One switch for the "local" LLM stack: when `LLM_BACKEND=llamacpp` is
/// set, chat / vision describe / embeddings all route through llama-swap
/// instead of Ollama. Any other value (including unset, the default) is
/// Ollama. This is intentionally global — embeddings must be drawn from
/// a single source or similarity search across the index breaks (mixed
/// vector spaces, possibly mixed dims). The `backend=hybrid` per-request
/// override remains orthogonal: it always sends chat to OpenRouter, and
/// uses `LLM_BACKEND` for the describe-then-inline vision pass.
pub fn local_backend_is_llamacpp() -> bool {
matches!(
std::env::var("LLM_BACKEND")
.ok()
.as_deref()
.map(|s| s.trim().to_lowercase())
.as_deref(),
Some("llamacpp")
)
}
/// Expected embedding dimensionality, env-overridable via `EMBEDDING_DIM`
/// (default 768, nomic-embed-text). Every store/query dim check reads this —
/// swapping to a different-dim model (e.g. Qwen3-Embedding-0.6B at 1024) is
/// then a config flip plus a `reembed_embeddings` run, not a code change.
/// Cached for the process lifetime; a flip requires a restart anyway since
/// the corpus must be re-embedded with it.
pub fn embedding_dim() -> usize {
static DIM: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
*DIM.get_or_init(|| {
std::env::var("EMBEDDING_DIM")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(768)
})
}
/// Read an embedding prefix from the environment. `.env` values can't hold
/// real newlines, so a literal `\n` in the value is expanded — Qwen3-style
/// query instructions need one ("Instruct: ...\nQuery: ").
fn embed_prefix(key: &str) -> String {
std::env::var(key)
.map(|v| v.replace("\\n", "\n"))
.unwrap_or_default()
}
/// Embed a search query. Applies `EMBED_QUERY_PREFIX` (default empty) —
/// retrieval models distinguish query-side from document-side text:
/// nomic v1.5 wants `search_query: `, Qwen3-Embedding wants
/// `Instruct: <task>\nQuery: `. Must pair with the document prefix the
/// corpus was embedded with or similarity degrades.
pub async fn embed_query(
ollama: &OllamaClient,
llamacpp: Option<&LlamaCppClient>,
text: &str,
) -> anyhow::Result<Vec<f32>> {
let prefixed = format!("{}{}", embed_prefix("EMBED_QUERY_PREFIX"), text);
embed_one(ollama, llamacpp, &prefixed).await
}
/// Embed corpus text (the stored side of retrieval). Applies
/// `EMBED_DOCUMENT_PREFIX` (default empty; nomic v1.5 wants
/// `search_document: `, Qwen3-Embedding wants none).
pub async fn embed_document(
ollama: &OllamaClient,
llamacpp: Option<&LlamaCppClient>,
text: &str,
) -> anyhow::Result<Vec<f32>> {
let prefixed = format!("{}{}", embed_prefix("EMBED_DOCUMENT_PREFIX"), text);
embed_one(ollama, llamacpp, &prefixed).await
}
/// Embed a batch of strings via the configured local backend. Routes
/// through llama-swap when `LLM_BACKEND=llamacpp` (and a client is
/// configured), else Ollama. See [`local_backend_is_llamacpp`] for the
/// rationale on consistency.
pub async fn embed_many(
ollama: &OllamaClient,
llamacpp: Option<&LlamaCppClient>,
texts: &[&str],
) -> anyhow::Result<Vec<Vec<f32>>> {
if local_backend_is_llamacpp() {
if let Some(lc) = llamacpp {
return <LlamaCppClient as LlmClient>::generate_embeddings(lc, texts).await;
}
anyhow::bail!(
"LLM_BACKEND=llamacpp but LlamaCppClient is unconfigured — \
set LLAMA_SWAP_URL or switch to LLM_BACKEND=ollama"
);
}
ollama.generate_embeddings(texts).await
}
/// Embed one string via the configured local backend. Single-text
/// convenience over [`embed_many`].
pub async fn embed_one(
ollama: &OllamaClient,
llamacpp: Option<&LlamaCppClient>,
text: &str,
) -> anyhow::Result<Vec<f32>> {
let mut vecs = embed_many(ollama, llamacpp, &[text]).await?;
vecs.pop()
.ok_or_else(|| anyhow::anyhow!("embedding backend returned no embeddings"))
}
#[cfg(test)]
mod env_dispatch_tests {
use super::*;
/// Env vars are process-global, and the test harness runs in parallel —
/// without this lock the `LLM_BACKEND` tests race each other and flake.
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_env<F: FnOnce()>(key: &str, val: Option<&str>, f: F) {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let prev = std::env::var(key).ok();
match val {
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
f();
match prev {
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
}
#[test]
fn llm_backend_defaults_to_ollama() {
with_env("LLM_BACKEND", None, || {
assert!(!local_backend_is_llamacpp());
});
}
#[test]
fn llm_backend_llamacpp_case_insensitive() {
with_env("LLM_BACKEND", Some("LlamaCpp"), || {
assert!(local_backend_is_llamacpp());
});
with_env("LLM_BACKEND", Some(" llamacpp "), || {
assert!(local_backend_is_llamacpp());
});
}
#[test]
fn llm_backend_unknown_value_is_ollama() {
with_env("LLM_BACKEND", Some("vllm"), || {
assert!(!local_backend_is_llamacpp());
});
}
}
+408
View File
@@ -0,0 +1,408 @@
//! Natural-language → structured-query translation for unified photo search.
//!
//! The unified search endpoint (`/photos/search/unified`, Phase 2) needs to
//! turn a free-text query like *"sunset photos in Italy from last summer"*
//! into the structured filter the existing `/photos` engine understands plus
//! a semantic term for CLIP ranking. That translation is a single grounded
//! LLM call, isolated here so it can be unit-tested without a network or the
//! full `InsightGenerator`.
//!
//! Two-stage design:
//! 1. The LLM emits a [`RawNlQuery`] — references are by *name* (tags) and
//! dates as ISO strings, never numeric ids it could hallucinate.
//! 2. [`resolve_raw_query`] maps names against the real tag vocabulary and
//! converts ISO dates to unix seconds, producing a [`StructuredQuery`].
//! A tag the model invents that isn't in the vocab is surfaced in
//! `unmatched_tags` (the caller folds it back into the semantic term)
//! rather than silently dropped — this is the anti-noise guard.
//!
//! Geocoding of `place` and person filtering are intentionally *not* handled
//! here: `place` stays as text for the caller to forward-geocode (async, see
//! `geo::forward_geocode`), and person filtering is deferred until a
//! person→photos resolver exists.
use crate::ai::llm_client::{ChatMessage, LlmClient, Tool, strip_think_blocks};
use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
/// Raw query object as emitted by the LLM. Tag references are by name
/// (resolved against the real vocab in Rust); dates are ISO `YYYY-MM-DD`.
/// Every field is optional so a partial / minimal model response still
/// deserializes.
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
pub struct RawNlQuery {
/// Visual/scene description handed to CLIP for ranking. The descriptive
/// remainder after structured filters are peeled off.
#[serde(default)]
pub semantic: Option<String>,
/// Tag names the photos must have. Matched case-insensitively against
/// the supplied vocabulary; non-matches land in `unmatched_tags`.
#[serde(default)]
pub tags: Vec<String>,
/// Tag names the photos must NOT have.
#[serde(default)]
pub exclude_tags: Vec<String>,
#[serde(default)]
pub camera_make: Option<String>,
#[serde(default)]
pub camera_model: Option<String>,
#[serde(default)]
pub lens_model: Option<String>,
/// Free-text place/location name to forward-geocode (e.g. "Italy").
#[serde(default)]
pub place: Option<String>,
/// Inclusive start date, ISO `YYYY-MM-DD`.
#[serde(default)]
pub date_from: Option<String>,
/// Inclusive end date, ISO `YYYY-MM-DD`.
#[serde(default)]
pub date_to: Option<String>,
/// "photo" | "video" — normalized in [`resolve_raw_query`].
#[serde(default)]
pub media_type: Option<String>,
}
/// Resolved structured query: tag names mapped to ids against the real
/// vocab, ISO dates converted to unix seconds. `place` stays as text for the
/// caller to forward-geocode into a gps circle. Serializable so the endpoint
/// can echo it back to the client as "this is how I read your query"
/// (editable filter chips).
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct StructuredQuery {
pub semantic: Option<String>,
pub tag_ids: Vec<i32>,
pub exclude_tag_ids: Vec<i32>,
/// Tag names the model produced that don't exist in the vocabulary.
/// The caller folds these back into the semantic term so the concept
/// isn't lost — and surfacing them keeps a hallucinated tag from
/// silently filtering the whole library to nothing.
pub unmatched_tags: Vec<String>,
pub camera_make: Option<String>,
pub camera_model: Option<String>,
pub lens_model: Option<String>,
/// Raw place name awaiting forward-geocoding by the caller.
pub place: Option<String>,
pub date_from: Option<i64>,
pub date_to: Option<i64>,
/// Normalized to "photo" | "video"; `None` means no media-type filter.
pub media_type: Option<String>,
}
/// Convert an ISO `YYYY-MM-DD` date to a unix timestamp (seconds). With
/// `end_of_day`, returns 23:59:59 of that day so a `date_to` filter is
/// inclusive of the whole day; otherwise 00:00:00. Returns `None` for any
/// unparseable input (the filter is simply omitted rather than erroring).
pub fn iso_to_unix(date: &str, end_of_day: bool) -> Option<i64> {
let d = chrono::NaiveDate::parse_from_str(date.trim(), "%Y-%m-%d").ok()?;
let time = if end_of_day {
chrono::NaiveTime::from_hms_opt(23, 59, 59)?
} else {
chrono::NaiveTime::from_hms_opt(0, 0, 0)?
};
Some(d.and_time(time).and_utc().timestamp())
}
/// Normalize a free-form media-type string to the engine's vocabulary.
/// Anything that isn't clearly photo or video (including "all") yields
/// `None` — no filter.
fn normalize_media_type(raw: &str) -> Option<String> {
match raw.trim().to_lowercase().as_str() {
"photo" | "photos" | "image" | "images" | "picture" | "pictures" => {
Some("photo".to_string())
}
"video" | "videos" | "movie" | "movies" | "clip" | "clips" => Some("video".to_string()),
_ => None,
}
}
/// Resolve a raw LLM query against the real tag vocabulary, producing the
/// structured filter. Pure — no network, no LLM — so it carries the
/// correctness-critical mapping logic under unit test.
///
/// `tag_vocab` is `(tag_id, tag_name)` pairs (the shape `TagDao::get_all_tags`
/// yields once the count is dropped). Matching is case-insensitive and exact
/// on the trimmed name.
pub fn resolve_raw_query(raw: RawNlQuery, tag_vocab: &[(i32, String)]) -> StructuredQuery {
// Case-insensitive name → id lookup. Built once per call.
let lookup: std::collections::HashMap<String, i32> = tag_vocab
.iter()
.map(|(id, name)| (name.trim().to_lowercase(), *id))
.collect();
let resolve_names = |names: &[String], ids: &mut Vec<i32>, unmatched: &mut Vec<String>| {
for name in names {
let key = name.trim().to_lowercase();
if key.is_empty() {
continue;
}
match lookup.get(&key) {
Some(id) if !ids.contains(id) => ids.push(*id),
Some(_) => {} // duplicate, already collected
None => {
if !unmatched.iter().any(|u| u.eq_ignore_ascii_case(name)) {
unmatched.push(name.trim().to_string());
}
}
}
}
};
let mut tag_ids = Vec::new();
let mut unmatched_tags = Vec::new();
resolve_names(&raw.tags, &mut tag_ids, &mut unmatched_tags);
// Excluded tags that don't match a real tag are simply ignored — you
// can't exclude a tag that doesn't exist, and folding them into
// `semantic` would make no sense.
let mut exclude_tag_ids = Vec::new();
let mut exclude_unmatched = Vec::new();
resolve_names(
&raw.exclude_tags,
&mut exclude_tag_ids,
&mut exclude_unmatched,
);
let clean = |s: Option<String>| s.map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
StructuredQuery {
semantic: clean(raw.semantic),
tag_ids,
exclude_tag_ids,
unmatched_tags,
camera_make: clean(raw.camera_make),
camera_model: clean(raw.camera_model),
lens_model: clean(raw.lens_model),
place: clean(raw.place),
date_from: raw.date_from.as_deref().and_then(|d| iso_to_unix(d, false)),
date_to: raw.date_to.as_deref().and_then(|d| iso_to_unix(d, true)),
media_type: raw.media_type.as_deref().and_then(normalize_media_type),
}
}
/// Build the grounded system prompt. The model is told the current date (so
/// "last summer" resolves) and the exact tag vocabulary (so it uses real
/// tags or routes the concept to `semantic` instead of inventing one).
fn build_system_prompt(tag_vocab: &[(i32, String)], today: chrono::NaiveDate) -> String {
// Cap the vocab dump so a huge library doesn't blow the context window;
// the most-used tags are the ones a query is likely to reference.
const MAX_TAGS: usize = 400;
let mut names: Vec<&str> = tag_vocab.iter().map(|(_, n)| n.as_str()).collect();
names.sort_unstable();
names.dedup();
let shown = names.len().min(MAX_TAGS);
let vocab = names[..shown].join(", ");
let truncation = if names.len() > MAX_TAGS {
format!(" (showing {MAX_TAGS} of {} tags)", names.len())
} else {
String::new()
};
format!(
"You translate a user's natural-language photo-search request into a JSON \
filter. Today's date is {today}. Respond with ONLY a JSON object, no prose, no \
code fences.\n\n\
Schema (all fields optional):\n\
{{\n \
\"semantic\": string|null, // visual scene/subject for image similarity search\n \
\"tags\": string[], // ONLY names from the tag list below\n \
\"exclude_tags\": string[], // ONLY names from the tag list below\n \
\"camera_make\": string|null,\n \
\"camera_model\": string|null,\n \
\"lens_model\": string|null,\n \
\"place\": string|null, // a location name to look up (city, country, landmark)\n \
\"date_from\": \"YYYY-MM-DD\"|null, // inclusive\n \
\"date_to\": \"YYYY-MM-DD\"|null, // inclusive\n \
\"media_type\": \"photo\"|\"video\"|null\n\
}}\n\n\
Rules:\n\
- Put descriptive/visual concepts (\"sunset\", \"crowded beach\", \"red car\") in \"semantic\".\n\
- Only use \"tags\"/\"exclude_tags\" values that appear EXACTLY in the tag list. If a \
concept isn't a listed tag, put it in \"semantic\" instead — never invent a tag.\n\
- Resolve relative dates against today's date (\"last summer\", \"2023\", \"last month\").\n\
- Put place/location names in \"place\" (not \"semantic\").\n\
- Omit (use null / empty array) anything the request doesn't mention.\n\n\
Available tags{truncation}: {vocab}"
)
}
/// Extract the JSON object from a model response that may include a leading
/// `<think>` block, code fences, or trailing prose. Strips the think block
/// first (so reasoning that mentions braces can't fool the scan), then
/// returns the substring from the first `{` to the last `}` inclusive — or
/// the trimmed text if no braces are found (which then fails to parse with a
/// clear error).
fn extract_json(raw: &str) -> String {
let s = strip_think_blocks(raw);
let start = s.find('{');
let end = s.rfind('}');
match (start, end) {
(Some(a), Some(b)) if b >= a => s[a..=b].to_string(),
_ => s.trim().to_string(),
}
}
/// Parse a model response string into a [`StructuredQuery`], resolving names
/// against the vocab. Separated from the LLM call so it's unit-testable.
pub fn parse_response(response: &str, tag_vocab: &[(i32, String)]) -> Result<StructuredQuery> {
let json = extract_json(response);
let raw: RawNlQuery = serde_json::from_str(&json)
.map_err(|e| anyhow!("failed to parse NL query JSON: {e}; raw response: {response:?}"))?;
Ok(resolve_raw_query(raw, tag_vocab))
}
/// Translate a natural-language query into a [`StructuredQuery`] via one
/// grounded LLM call. The `client` is any configured backend (the unified
/// endpoint passes the resolved chat backend); `tag_vocab` grounds the tag
/// mapping; `today` anchors relative-date resolution.
pub async fn translate_nl_query(
client: &dyn LlmClient,
nl: &str,
tag_vocab: &[(i32, String)],
today: chrono::NaiveDate,
) -> Result<StructuredQuery> {
let system = build_system_prompt(tag_vocab, today);
let messages = vec![ChatMessage::system(system), ChatMessage::user(nl)];
let (msg, _, _) = client.chat_with_tools(messages, Vec::<Tool>::new()).await?;
parse_response(&msg.content, tag_vocab)
}
#[cfg(test)]
mod tests {
use super::*;
fn vocab() -> Vec<(i32, String)> {
vec![
(1, "beach".to_string()),
(2, "Sunset".to_string()), // mixed case to exercise case-insensitivity
(3, "family".to_string()),
]
}
#[test]
fn iso_to_unix_start_and_end_of_day() {
// 2023-01-01 UTC midnight = 1672531200.
assert_eq!(iso_to_unix("2023-01-01", false), Some(1_672_531_200));
// End of that day is 86399 seconds later.
assert_eq!(
iso_to_unix("2023-01-01", true),
Some(1_672_531_200 + 86_399)
);
}
#[test]
fn iso_to_unix_rejects_garbage() {
assert_eq!(iso_to_unix("last summer", false), None);
assert_eq!(iso_to_unix("2023-13-99", false), None);
assert_eq!(iso_to_unix("", false), None);
}
#[test]
fn resolve_matches_tags_case_insensitively() {
let raw = RawNlQuery {
tags: vec!["BEACH".to_string(), "sunset".to_string()],
..Default::default()
};
let q = resolve_raw_query(raw, &vocab());
assert_eq!(q.tag_ids, vec![1, 2]);
assert!(q.unmatched_tags.is_empty());
}
#[test]
fn resolve_surfaces_unmatched_tags_not_silently_dropped() {
// A hallucinated / non-vocab tag must be surfaced so the caller can
// fold it into semantic — never silently used as a hard filter.
let raw = RawNlQuery {
tags: vec!["beach".to_string(), "golden hour".to_string()],
..Default::default()
};
let q = resolve_raw_query(raw, &vocab());
assert_eq!(q.tag_ids, vec![1]);
assert_eq!(q.unmatched_tags, vec!["golden hour".to_string()]);
}
#[test]
fn resolve_dedups_repeated_tags() {
let raw = RawNlQuery {
tags: vec![
"beach".to_string(),
"Beach".to_string(),
"beach".to_string(),
],
..Default::default()
};
let q = resolve_raw_query(raw, &vocab());
assert_eq!(q.tag_ids, vec![1]);
}
#[test]
fn resolve_normalizes_media_type_and_dates() {
let raw = RawNlQuery {
media_type: Some("Videos".to_string()),
date_from: Some("2023-06-01".to_string()),
date_to: Some("2023-06-30".to_string()),
..Default::default()
};
let q = resolve_raw_query(raw, &vocab());
assert_eq!(q.media_type.as_deref(), Some("video"));
assert_eq!(q.date_from, iso_to_unix("2023-06-01", false));
assert_eq!(q.date_to, iso_to_unix("2023-06-30", true));
}
#[test]
fn resolve_media_type_all_is_no_filter() {
let raw = RawNlQuery {
media_type: Some("all".to_string()),
..Default::default()
};
assert_eq!(resolve_raw_query(raw, &vocab()).media_type, None);
}
#[test]
fn resolve_trims_and_empties_to_none() {
let raw = RawNlQuery {
semantic: Some(" ".to_string()),
camera_make: Some(" Fujifilm ".to_string()),
place: Some("".to_string()),
..Default::default()
};
let q = resolve_raw_query(raw, &vocab());
assert_eq!(q.semantic, None);
assert_eq!(q.camera_make.as_deref(), Some("Fujifilm"));
assert_eq!(q.place, None);
}
#[test]
fn parse_response_handles_code_fences_and_prose() {
let resp = "Here is the filter:\n```json\n{\"semantic\":\"sunset\",\"tags\":[\"beach\"]}\n```\nDone.";
let q = parse_response(resp, &vocab()).expect("parse");
assert_eq!(q.semantic.as_deref(), Some("sunset"));
assert_eq!(q.tag_ids, vec![1]);
}
#[test]
fn parse_response_handles_think_block_then_json() {
let resp = "<think>user wants beach sunsets</think>{\"tags\":[\"beach\",\"sunset\"]}";
let q = parse_response(resp, &vocab()).expect("parse");
assert_eq!(q.tag_ids, vec![1, 2]);
}
#[test]
fn parse_response_errors_on_non_json() {
assert!(parse_response("I cannot help with that.", &vocab()).is_err());
}
#[test]
fn build_system_prompt_includes_date_and_vocab() {
let today = chrono::NaiveDate::from_ymd_opt(2026, 6, 14).unwrap();
let prompt = build_system_prompt(&vocab(), today);
assert!(
prompt.contains("2026-06-14"),
"prompt should state today's date"
);
assert!(prompt.contains("beach"), "prompt should list the vocab");
assert!(
prompt.contains("never invent a tag"),
"prompt should warn against inventing tags"
);
}
}
+68 -23
View File
@@ -360,18 +360,7 @@ impl OllamaClient {
/// Extract final answer from thinking model output /// Extract final answer from thinking model output
/// Handles <think>...</think> tags and takes everything after /// Handles <think>...</think> tags and takes everything after
fn extract_final_answer(&self, response: &str) -> String { fn extract_final_answer(&self, response: &str) -> String {
let response = response.trim(); crate::ai::llm_client::strip_think_blocks(response)
// Look for </think> tag and take everything after it
if let Some(pos) = response.find("</think>") {
let answer = response[pos + 8..].trim();
if !answer.is_empty() {
return answer.to_string();
}
}
// Fallback: return the whole response trimmed
response.to_string()
} }
async fn try_generate( async fn try_generate(
@@ -424,10 +413,7 @@ impl OllamaClient {
self.generate_with_images(prompt, system, None).await self.generate_with_images(prompt, system, None).await
} }
/// Variant of `generate` that sets Ollama's top-level `think: false`. #[allow(dead_code)]
/// Used by latency-sensitive callers like the rerank pass, where the
/// task has nothing to reason about and chain-of-thought tokens are
/// wasted wall time. Server-side no-op on non-reasoning models.
pub async fn generate_no_think(&self, prompt: &str, system: Option<&str>) -> Result<String> { pub async fn generate_no_think(&self, prompt: &str, system: Option<&str>) -> Result<String> {
self.generate_with_options(prompt, system, None, Some(false)) self.generate_with_options(prompt, system, None, Some(false))
.await .await
@@ -562,7 +548,16 @@ Capture the key moment or theme. Return ONLY the title, nothing else."#,
let title = self let title = self
.generate_with_images(&prompt, Some(system), None) .generate_with_images(&prompt, Some(system), None)
.await?; .await?;
Ok(title.trim().trim_matches('"').to_string()) // Models decorate despite "Return ONLY the title": quotes, bold
// markers, sometimes a "Title:" label.
use crate::ai::insight_generator::strip_title_markdown;
let cleaned = strip_title_markdown(title.trim());
let cleaned = cleaned
.strip_prefix("Title:")
.or_else(|| cleaned.strip_prefix("title:"))
.map(strip_title_markdown)
.unwrap_or(cleaned);
Ok(cleaned.to_string())
} }
/// Generate a summary for a single photo based on its context /// Generate a summary for a single photo based on its context
@@ -849,11 +844,14 @@ Analyze the image and use specific details from both the visual content and the
if !chunk.message.role.is_empty() { if !chunk.message.role.is_empty() {
role = chunk.message.role; role = chunk.message.role;
} }
// Ollama only attaches tool_calls on the final chunk. // Ollama ≥0.8 can stream tool_calls incrementally
// across chunks (older servers attach them all to
// one chunk) — append rather than overwrite so
// calls from earlier chunks survive.
if let Some(tcs) = chunk.message.tool_calls if let Some(tcs) = chunk.message.tool_calls
&& !tcs.is_empty() && !tcs.is_empty()
{ {
tool_calls = Some(tcs); append_streamed_tool_calls(&mut tool_calls, tcs);
} }
if chunk.done { if chunk.done {
prompt_eval_count = chunk.prompt_eval_count; prompt_eval_count = chunk.prompt_eval_count;
@@ -1057,13 +1055,14 @@ Analyze the image and use specific details from both the visual content and the
} }
}; };
// Validate embedding dimensions (should be 768 for nomic-embed-text:v1.5) // Validate embedding dimensions (EMBEDDING_DIM; 768 for nomic-embed-text:v1.5)
for (i, embedding) in embeddings.iter().enumerate() { for (i, embedding) in embeddings.iter().enumerate() {
if embedding.len() != 768 { if embedding.len() != crate::ai::embedding_dim() {
log::warn!( log::warn!(
"Unexpected embedding dimensions for item {}: {} (expected 768)", "Unexpected embedding dimensions for item {}: {} (expected {})",
i, i,
embedding.len() embedding.len(),
crate::ai::embedding_dim()
); );
} }
} }
@@ -1332,8 +1331,20 @@ struct OllamaEmbedResponse {
embeddings: Vec<Vec<f32>>, embeddings: Vec<Vec<f32>>,
} }
/// Accumulate tool calls streamed across NDJSON chunks. Ollama ≥0.8 may
/// emit each tool call on its own chunk; replacing the accumulator on every
/// chunk would keep only the last call, so extend instead.
fn append_streamed_tool_calls(
acc: &mut Option<Vec<crate::ai::llm_client::ToolCall>>,
new: Vec<crate::ai::llm_client::ToolCall>,
) {
acc.get_or_insert_with(Vec::new).extend(new);
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::append_streamed_tool_calls;
use crate::ai::llm_client::{ToolCall, ToolCallFunction};
#[test] #[test]
fn generate_photo_description_prompt_is_concise() { fn generate_photo_description_prompt_is_concise() {
@@ -1344,4 +1355,38 @@ mod tests {
Focus on the people, location, and activity."; Focus on the people, location, and activity.";
assert!(prompt.len() < 200, "Prompt should be concise"); assert!(prompt.len() < 200, "Prompt should be concise");
} }
fn call(name: &str) -> ToolCall {
ToolCall {
id: None,
function: ToolCallFunction {
name: name.to_string(),
arguments: serde_json::json!({}),
},
}
}
#[test]
fn streamed_tool_calls_across_chunks_accumulate() {
// Two tool calls arriving in two separate stream chunks must BOTH
// survive assembly — the old `tool_calls = Some(tcs)` kept only the
// last chunk's calls.
let mut acc: Option<Vec<ToolCall>> = None;
append_streamed_tool_calls(&mut acc, vec![call("get_sms_messages")]);
append_streamed_tool_calls(&mut acc, vec![call("reverse_geocode")]);
let calls = acc.expect("tool calls accumulated");
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].function.name, "get_sms_messages");
assert_eq!(calls[1].function.name, "reverse_geocode");
}
#[test]
fn streamed_tool_calls_single_chunk_batch_kept_intact() {
// Older Ollama servers attach all calls to one chunk — unchanged.
let mut acc: Option<Vec<ToolCall>> = None;
append_streamed_tool_calls(&mut acc, vec![call("a"), call("b")]);
let calls = acc.expect("tool calls accumulated");
assert_eq!(calls.len(), 2);
}
} }
+282
View File
@@ -0,0 +1,282 @@
// User-configurable pronunciation overrides for TTS. Chatterbox mispronounces
// place names ("Worcester"), initialisms ("WSL"), and clipped abbreviations
// ("blvd"), so we rewrite them to phonetic spellings before synthesis.
//
// The map lives in a JSON file on the server — a flat object of
// `"written form": "spoken form"` pairs, e.g.:
//
// {
// "Worcester": "Wuster",
// "WSL": "W S L",
// "blvd": "boulevard",
// "Dr.": "Doctor"
// }
//
// Path comes from `TTS_PRONUNCIATIONS_PATH` (default `tts_pronunciations.json`
// in the working directory). A missing file simply disables the feature. The
// file is re-read whenever its mtime changes, so edits apply to the next
// synthesis without a restart; a malformed edit keeps the last good map and
// logs the parse error instead of silently dropping all overrides.
//
// Matching rules:
// - Whole words only — `cat` never rewrites `category`. (Boundaries are only
// asserted next to word characters, so keys like `Dr.` still work.)
// - Smartcase: an all-lowercase key matches case-insensitively; a key with
// any uppercase matches exactly. That lets `worcester` catch every casing
// while `US` (the country) leaves the pronoun `us` alone.
// - Longer keys win over shorter ones (`New York Times` before `New York`).
use regex::Regex;
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, LazyLock, Mutex as StdMutex};
use std::time::SystemTime;
/// A compiled pronunciation map: one alternation regex over every key plus
/// the lookup tables the replacement closure resolves matches against.
#[derive(Default)]
struct CompiledMap {
/// `None` when the map is empty — apply() is then a no-op.
regex: Option<Regex>,
/// Case-sensitive entries, keyed verbatim.
exact: HashMap<String, String>,
/// Case-insensitive entries, keyed lowercased.
folded: HashMap<String, String>,
}
impl CompiledMap {
fn from_entries(entries: &HashMap<String, String>) -> Self {
let mut keys: Vec<&str> = entries
.keys()
.map(|k| k.as_str())
.filter(|k| !k.trim().is_empty())
.collect();
if keys.is_empty() {
return Self::default();
}
// Longest key first so overlapping entries prefer the more specific
// one (regex alternation is first-match-wins, not longest-match).
keys.sort_by(|a, b| b.len().cmp(&a.len()).then(a.cmp(b)));
let mut exact = HashMap::new();
let mut folded = HashMap::new();
let alternatives: Vec<String> = keys
.iter()
.map(|key| {
let escaped = regex::escape(key);
// Only assert a word boundary where the key edge is a word
// character — `\b` adjacent to punctuation (e.g. the dot in
// `Dr.`) would otherwise never match.
let lead = if key
.chars()
.next()
.is_some_and(|c| c.is_alphanumeric() || c == '_')
{
r"\b"
} else {
""
};
let trail = if key
.chars()
.last()
.is_some_and(|c| c.is_alphanumeric() || c == '_')
{
r"\b"
} else {
""
};
let case_sensitive = key.chars().any(|c| c.is_uppercase());
if case_sensitive {
exact.insert(key.to_string(), entries[*key].clone());
format!("{lead}{escaped}{trail}")
} else {
folded.insert(key.to_lowercase(), entries[*key].clone());
format!("{lead}(?i:{escaped}){trail}")
}
})
.collect();
// Escaped fixed strings can't produce an invalid pattern; if one ever
// does, treat the whole map as empty rather than panicking a handler.
let pattern = alternatives.join("|");
let regex = match Regex::new(&pattern) {
Ok(r) => Some(r),
Err(e) => {
log::error!("pronunciation map failed to compile: {e}");
None
}
};
Self {
regex,
exact,
folded,
}
}
fn apply(&self, text: &str) -> String {
let Some(re) = &self.regex else {
return text.to_string();
};
re.replace_all(text, |caps: &regex::Captures| {
let m = &caps[0];
self.exact
.get(m)
.or_else(|| self.folded.get(&m.to_lowercase()))
.cloned()
// Unreachable in practice — every alternative came from one
// of the two maps — but never drop the user's text.
.unwrap_or_else(|| m.to_string())
})
.into_owned()
}
}
struct CacheEntry {
mtime: Option<SystemTime>,
compiled: Arc<CompiledMap>,
}
static CACHE: LazyLock<StdMutex<Option<CacheEntry>>> = LazyLock::new(|| StdMutex::new(None));
fn config_path() -> String {
std::env::var("TTS_PRONUNCIATIONS_PATH")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "tts_pronunciations.json".to_string())
}
/// Load the compiled map, re-reading the file only when its mtime changed
/// since the last call (or it appeared/disappeared). Synthesis is serialized
/// on a single GPU permit, so a stat per call is noise.
fn current_map() -> Arc<CompiledMap> {
let path_s = config_path();
let path = Path::new(&path_s);
let mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok();
let mut cache = CACHE.lock().unwrap();
if let Some(entry) = cache.as_ref()
&& entry.mtime == mtime
{
return entry.compiled.clone();
}
let compiled = match mtime {
None => Arc::new(CompiledMap::default()), // no file → no overrides
Some(_) => match std::fs::read_to_string(path)
.map_err(anyhow::Error::from)
.and_then(|s| Ok(serde_json::from_str::<HashMap<String, String>>(&s)?))
{
Ok(entries) => {
log::info!(
"loaded {} pronunciation override(s) from {path_s}",
entries.len()
);
Arc::new(CompiledMap::from_entries(&entries))
}
Err(e) => {
log::error!("failed to load pronunciation map {path_s}: {e}");
// Keep serving the previous map rather than regressing to
// none mid-edit; still record the new mtime so the error
// logs once per bad save, not once per synthesis.
cache
.as_ref()
.map(|c| c.compiled.clone())
.unwrap_or_default()
}
},
};
*cache = Some(CacheEntry {
mtime,
compiled: compiled.clone(),
});
compiled
}
/// Rewrite configured words/abbreviations to their phonetic spellings.
/// Call on cleaned (post-markdown-strip) text, right before synthesis.
pub fn apply_pronunciations(text: &str) -> String {
current_map().apply(text)
}
#[cfg(test)]
mod tests {
use super::*;
fn compile(pairs: &[(&str, &str)]) -> CompiledMap {
let entries = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
CompiledMap::from_entries(&entries)
}
#[test]
fn empty_map_is_a_noop() {
let m = compile(&[]);
assert_eq!(m.apply("nothing changes"), "nothing changes");
}
#[test]
fn replaces_whole_words_only() {
let m = compile(&[("cat", "kitty")]);
assert_eq!(m.apply("the cat sat"), "the kitty sat");
// No substring rewrites.
assert_eq!(m.apply("the category"), "the category");
assert_eq!(m.apply("concatenate"), "concatenate");
}
#[test]
fn lowercase_keys_match_any_casing() {
let m = compile(&[("worcester", "Wuster")]);
assert_eq!(m.apply("Worcester is nice"), "Wuster is nice");
assert_eq!(m.apply("in WORCESTER today"), "in Wuster today");
assert_eq!(m.apply("worcester sauce"), "Wuster sauce");
}
#[test]
fn uppercase_keys_match_case_sensitively() {
let m = compile(&[("US", "U S")]);
assert_eq!(m.apply("the US economy"), "the U S economy");
// The pronoun survives.
assert_eq!(m.apply("join us today"), "join us today");
}
#[test]
fn keys_with_punctuation_work() {
// `\b` is only asserted next to word characters, so the trailing dot
// doesn't break matching.
let m = compile(&[("Dr.", "Doctor"), ("blvd", "boulevard")]);
assert_eq!(
m.apply("Dr. Smith on Sunset blvd"),
"Doctor Smith on Sunset boulevard"
);
}
#[test]
fn longer_keys_win_over_shorter() {
let m = compile(&[("new york", "Noo York"), ("new york times", "the Times")]);
assert_eq!(m.apply("read the new york times"), "read the the Times");
assert_eq!(m.apply("visit new york soon"), "visit Noo York soon");
}
#[test]
fn multiple_occurrences_all_rewrite() {
let m = compile(&[("wsl", "W S L")]);
assert_eq!(m.apply("WSL and wsl and Wsl"), "W S L and W S L and W S L");
}
#[test]
fn replacement_text_is_verbatim() {
// Replacements aren't re-scanned — a value containing another key
// doesn't cascade.
let m = compile(&[("a1", "b2"), ("b2", "c3")]);
assert_eq!(m.apply("a1"), "b2");
}
#[test]
fn blank_keys_are_ignored() {
let m = compile(&[("", "x"), (" ", "y"), ("ok", "fine")]);
assert_eq!(m.apply("ok then"), "fine then");
}
}
+103 -19
View File
@@ -20,31 +20,36 @@ impl SmsApiClient {
} }
} }
/// Fetch messages for a specific contact within ±4 days of the given timestamp /// Compute a `[start, end]` unix-second window of `2 * radius_days`
/// Falls back to all contacts if no messages found for the specific contact /// centered on `center_ts`. `radius_days < 1` is clamped to 1 to avoid
/// Messages are sorted by proximity to the center timestamp /// degenerate zero-width windows.
pub(crate) fn window_for_radius(center_ts: i64, radius_days: i64) -> (i64, i64) {
let r = radius_days.max(1);
let span = r * 86400;
(center_ts - span, center_ts + span)
}
/// Fetch messages for a specific contact within ±`radius_days` of the
/// given timestamp. Falls back to all contacts when no messages found
/// for the named contact. Sorted by proximity to the center timestamp.
pub async fn fetch_messages_for_contact( pub async fn fetch_messages_for_contact(
&self, &self,
contact: Option<&str>, contact: Option<&str>,
center_timestamp: i64, center_timestamp: i64,
radius_days: i64,
) -> Result<Vec<SmsMessage>> { ) -> Result<Vec<SmsMessage>> {
use chrono::Duration; let effective_radius = radius_days.max(1);
let (start_ts, end_ts) = Self::window_for_radius(center_timestamp, radius_days);
// Calculate ±4 days range around the center timestamp
let center_dt = chrono::DateTime::from_timestamp(center_timestamp, 0) let center_dt = chrono::DateTime::from_timestamp(center_timestamp, 0)
.ok_or_else(|| anyhow::anyhow!("Invalid timestamp"))?; .ok_or_else(|| anyhow::anyhow!("Invalid timestamp"))?;
let start_dt = center_dt - Duration::days(4);
let end_dt = center_dt + Duration::days(4);
let start_ts = start_dt.timestamp();
let end_ts = end_dt.timestamp();
// If contact specified, try fetching for that contact first // If contact specified, try fetching for that contact first
if let Some(contact_name) = contact { if let Some(contact_name) = contact {
log::info!( log::info!(
"Fetching SMS for contact: {} (±4 days from {})", "Fetching SMS for contact: {} (±{} days from {})",
contact_name, contact_name,
effective_radius,
center_dt.format("%Y-%m-%d %H:%M:%S") center_dt.format("%Y-%m-%d %H:%M:%S")
); );
let messages = self let messages = self
@@ -68,7 +73,8 @@ impl SmsApiClient {
// Fallback to all contacts // Fallback to all contacts
log::info!( log::info!(
"Fetching all SMS messages (±4 days from {})", "Fetching all SMS messages (±{} days from {})",
effective_radius,
center_dt.format("%Y-%m-%d %H:%M:%S") center_dt.format("%Y-%m-%d %H:%M:%S")
); );
self.fetch_messages(start_ts, end_ts, None, Some(center_timestamp)) self.fetch_messages(start_ts, end_ts, None, Some(center_timestamp))
@@ -251,23 +257,48 @@ impl SmsApiClient {
} }
/// Search message bodies via the Django side's FTS5 / semantic / hybrid /// Search message bodies via the Django side's FTS5 / semantic / hybrid
/// endpoint. `mode` selects the ranking strategy: /// endpoint. `params.mode` selects the ranking strategy:
/// - "fts5" keyword-only, supports phrase / prefix / boolean / NEAR /// - "fts5" keyword-only, supports phrase / prefix / boolean / NEAR
/// - "semantic" embedding similarity /// - "semantic" embedding similarity
/// - "hybrid" both merged via reciprocal rank fusion (recommended) /// - "hybrid" both merged via reciprocal rank fusion (recommended)
///
/// All of `contact_id`, `date_from` / `date_to` (unix seconds), `is_mms`,
/// `has_media`, and `offset` are pushed to SMS-API server-side so the
/// filtered+paginated result set is exact rather than a client-side
/// over-fetch.
pub async fn search_messages( pub async fn search_messages(
&self, &self,
query: &str, query: &str,
mode: &str, params: &SmsSearchParams<'_>,
limit: usize,
) -> Result<Vec<SmsSearchHit>> { ) -> Result<Vec<SmsSearchHit>> {
let url = format!( let mut url = format!(
"{}/api/messages/search/?q={}&mode={}&limit={}", "{}/api/messages/search/?q={}&mode={}&limit={}",
self.base_url, self.base_url,
urlencoding::encode(query), urlencoding::encode(query),
urlencoding::encode(mode), urlencoding::encode(params.mode),
limit params.limit,
); );
if let Some(cid) = params.contact_id {
url.push_str(&format!("&contact_id={}", cid));
}
if let Some(ref c) = params.contact {
url.push_str(&format!("&contact={}", urlencoding::encode(c)));
}
if let Some(off) = params.offset {
url.push_str(&format!("&offset={}", off));
}
if let Some(from) = params.date_from {
url.push_str(&format!("&date_from={}", from));
}
if let Some(to) = params.date_to {
url.push_str(&format!("&date_to={}", to));
}
if let Some(is_mms) = params.is_mms {
url.push_str(&format!("&is_mms={}", is_mms));
}
if let Some(has_media) = params.has_media {
url.push_str(&format!("&has_media={}", has_media));
}
let mut request = self.client.get(&url); let mut request = self.client.get(&url);
if let Some(token) = &self.token { if let Some(token) = &self.token {
@@ -370,6 +401,33 @@ pub struct SmsSearchHit {
/// Present for semantic / hybrid modes; absent for fts5. /// Present for semantic / hybrid modes; absent for fts5.
#[serde(default)] #[serde(default)]
pub similarity_score: Option<f32>, pub similarity_score: Option<f32>,
/// SMS-API-generated excerpt around the match, wrapped in `<mark>` tags.
/// For MMS messages that only matched via attachment text / filename
/// (empty `body`), the snippet is the only meaningful preview.
#[serde(default)]
pub snippet: Option<String>,
}
/// Optional filter / paging knobs for [`SmsApiClient::search_messages`].
/// All fields except `mode` and `limit` map 1:1 to the same-named SMS-API
/// query params (added in the 2026-05 search-enhancements release).
#[derive(Debug, Clone)]
pub struct SmsSearchParams<'a> {
pub mode: &'a str,
pub limit: usize,
pub contact_id: Option<i64>,
/// Contact name (case-insensitive). Resolved to a numeric ID by the
/// SMS-API server when `contact_id` is not set.
pub contact: Option<String>,
/// Unix-seconds inclusive lower bound on `date`.
pub date_from: Option<i64>,
/// Unix-seconds inclusive upper bound on `date`.
pub date_to: Option<i64>,
/// `Some(true)` = MMS only, `Some(false)` = SMS only, `None` = both.
pub is_mms: Option<bool>,
/// `Some(true)` = only messages with image/video/audio attachments.
pub has_media: Option<bool>,
pub offset: Option<usize>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -379,3 +437,29 @@ struct SmsSearchResponse {
#[serde(default)] #[serde(default)]
search_method: String, search_method: String,
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn window_for_radius_produces_2n_day_span() {
let center: i64 = 1_700_000_000;
let (start, end) = SmsApiClient::window_for_radius(center, 7);
assert_eq!(end - start, 14 * 86400);
assert_eq!(start + 7 * 86400, center);
assert_eq!(end - 7 * 86400, center);
}
#[test]
fn window_for_radius_clamps_zero_to_one() {
let (start, end) = SmsApiClient::window_for_radius(100_000, 0);
assert_eq!(end - start, 2 * 86400);
}
#[test]
fn window_for_radius_clamps_negative_to_one() {
let (start, end) = SmsApiClient::window_for_radius(100_000, -7);
assert_eq!(end - start, 2 * 86400);
}
}
+1313
View File
File diff suppressed because it is too large Load Diff
+748
View File
@@ -0,0 +1,748 @@
use crate::ai::insight_chat::ChatStreamEvent;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Instant;
use tokio::sync::{Mutex, Notify};
use tokio::task::AbortHandle;
/// Maximum number of events buffered per turn. Agentic turns typically
/// produce ~120 events; 500 provides 4× headroom. When exceeded, oldest
/// events are evicted from the front.
const MAX_BUFFERED_EVENTS: usize = 500;
/// Turn status codes used by `TurnEntry::status`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TurnStatus {
Running = 0,
Done = 1,
Error = 2,
Cancelled = 3,
}
impl From<u32> for TurnStatus {
fn from(v: u32) -> Self {
match v {
0 => TurnStatus::Running,
1 => TurnStatus::Done,
2 => TurnStatus::Error,
3 => TurnStatus::Cancelled,
_ => TurnStatus::Running,
}
}
}
impl TurnStatus {
pub fn as_str(&self) -> &'static str {
match self {
TurnStatus::Running => "running",
TurnStatus::Done => "done",
TurnStatus::Error => "error",
TurnStatus::Cancelled => "cancelled",
}
}
}
/// Shared metadata about a turn, read by the SSE replay handler to emit
/// the initial `turn_info` event and to decide whether to wait for new
/// events or close immediately.
#[derive(Debug, Clone)]
pub struct TurnInfo {
pub turn_id: String,
pub file_path: String,
pub library_id: i32,
pub status: TurnStatus,
pub total_events_pushed: u32,
pub buffered_count: u32,
}
/// Result of reading events at or after an absolute `skip_before` index.
#[derive(Debug)]
pub enum ReplayOutcome {
/// New events are available. `next_skip` is the absolute index to pass
/// on the next read (i.e. one past the last event returned).
Events {
events: Vec<ChatStreamEvent>,
next_skip: u32,
},
/// The reader is caught up to the live edge — no events past `skip_before`
/// yet. `next_skip` is the current high-water mark.
CaughtUp { next_skip: u32 },
/// `skip_before` points below the buffer's base index: the requested
/// events were evicted. Maps to HTTP 410 Gone.
Gone,
}
/// Per-turn state shared between the agentic loop (writer) and all SSE
/// replay connections (readers).
pub struct TurnEntry {
pub turn_id: String,
pub file_path: String,
pub library_id: i32,
/// Shared event buffer — multiple SSE connections can read independently.
/// Each connection tracks its own `skip_before` offset.
events: Mutex<Vec<ChatStreamEvent>>,
/// Monotonic counter: total events pushed (may exceed events.len()
/// due to eviction). Used for skip_before indexing.
total_events_pushed: AtomicU32,
/// The event index that this entry started with. Adjusts on eviction
/// so that `skip_before` stays absolute across connections.
base_index: AtomicU32,
pub status: AtomicU32,
/// Abort handle for the spawned agentic task, set once after spawn.
/// Behind a std `Mutex` because the entry is shared via `Arc` and the
/// handle is installed after the entry is already in the registry.
abort_handle: StdMutex<Option<AbortHandle>>,
pub created_at: Instant,
notify: Arc<Notify>,
}
impl TurnEntry {
pub fn new(turn_id: String, file_path: String, library_id: i32) -> Self {
Self {
turn_id,
file_path,
library_id,
events: Mutex::new(Vec::new()),
total_events_pushed: AtomicU32::new(0),
base_index: AtomicU32::new(0),
status: AtomicU32::new(TurnStatus::Running as u32),
abort_handle: StdMutex::new(None),
created_at: Instant::now(),
notify: Arc::new(Notify::new()),
}
}
/// Install the abort handle for the spawned agentic task. Called once,
/// right after the task is spawned.
pub fn set_abort_handle(&self, handle: AbortHandle) {
*self.abort_handle.lock().expect("abort_handle poisoned") = Some(handle);
}
/// Abort the spawned agentic task, if a handle was installed. Returns
/// `true` if a task was aborted.
pub fn abort(&self) -> bool {
if let Some(handle) = self
.abort_handle
.lock()
.expect("abort_handle poisoned")
.take()
{
handle.abort();
true
} else {
false
}
}
/// Push an event into the buffer. Evicts oldest events if the buffer
/// exceeds `MAX_BUFFERED_EVENTS`. Notifies all waiting SSE connections.
pub async fn push_event(&self, event: ChatStreamEvent) {
{
let mut events = self.events.lock().await;
// Evict oldest events if we've hit the cap.
if events.len() >= MAX_BUFFERED_EVENTS {
// Drop the oldest event to make room and advance the base
// index so skip_before stays absolute across connections.
events.remove(0);
self.base_index.fetch_add(1, Ordering::Relaxed);
}
events.push(event);
// Increment while holding the buffer lock so the counter stays in
// lock-step with the buffer even if multiple writers ever exist.
self.total_events_pushed.fetch_add(1, Ordering::Relaxed);
}
self.notify.notify_waiters();
}
/// Get a snapshot of turn metadata for the `turn_info` SSE event.
pub async fn info(&self) -> TurnInfo {
let events = self.events.lock().await;
let buffered = events.len() as u32;
let total = self.total_events_pushed.load(Ordering::Relaxed);
drop(events);
TurnInfo {
turn_id: self.turn_id.clone(),
file_path: self.file_path.clone(),
library_id: self.library_id,
status: self.status.load(Ordering::Relaxed).into(),
total_events_pushed: total,
buffered_count: buffered,
}
}
/// Set the terminal status and notify all waiters.
pub fn set_terminal_status(&self, status: TurnStatus) {
self.status.store(status as u32, Ordering::Relaxed);
self.notify.notify_waiters();
}
/// Read buffered events at or after absolute index `skip_before` without
/// waiting. Distinguishes "evicted" (Gone) from "caught up" (no new
/// events yet) — the previous boolean/`Option` API conflated the two.
pub async fn replay_from(&self, skip_before: u32) -> ReplayOutcome {
let events = self.events.lock().await;
let base = self.base_index.load(Ordering::Relaxed);
// The buffer holds absolute indices [base, base + len). A request
// below `base` asked for events that have been evicted.
if skip_before < base {
return ReplayOutcome::Gone;
}
let offset = (skip_before - base) as usize;
let next_skip = base + events.len() as u32;
if offset >= events.len() {
// Caught up to (or past) the live edge — nothing new yet.
return ReplayOutcome::CaughtUp { next_skip };
}
ReplayOutcome::Events {
events: events[offset..].to_vec(),
next_skip,
}
}
/// Wait for the next batch of events past `skip_before`, the turn to
/// finish, or eviction. Returns:
/// - `Events` when new events are available (drained before any terminal
/// signal so the final `Done`/`Error` is never dropped),
/// - `CaughtUp` only when the turn has reached a terminal status and the
/// reader is fully drained (the caller should close the stream),
/// - `Gone` when `skip_before` points into evicted territory.
pub async fn next_batch(&self, skip_before: u32) -> ReplayOutcome {
loop {
// Register interest BEFORE inspecting state so a push/terminal that
// races between our read and our await can't be lost (Notify's
// `notify_waiters` does not store a permit).
let notified = self.notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
match self.replay_from(skip_before).await {
ReplayOutcome::CaughtUp { next_skip } => {
// No new events. If the turn is finished, every event
// (including the terminal one) has already been drained
// above on a prior call, so signal the caller to close.
if !self.is_running() {
return ReplayOutcome::CaughtUp { next_skip };
}
// Still running — wait for the next push or terminal.
}
other => return other, // Events or Gone
}
notified.await;
}
}
/// Check if this turn is still running.
pub fn is_running(&self) -> bool {
self.status.load(Ordering::Relaxed) == TurnStatus::Running as u32
}
}
/// In-memory registry of all active chat turns. Injected into `AppState`
/// and shared across all handlers.
pub struct TurnRegistry {
entries: Mutex<HashMap<String, Arc<TurnEntry>>>,
timeout_secs: u64,
}
impl TurnRegistry {
pub fn new(timeout_secs: u64) -> Self {
Self {
entries: Mutex::new(HashMap::new()),
timeout_secs,
}
}
/// Returns the cleanup timeout in seconds.
pub fn timeout_secs(&self) -> u64 {
self.timeout_secs
}
/// Insert a new turn entry. Returns the turn_id.
pub async fn insert(&self, entry: Arc<TurnEntry>) -> String {
let turn_id = entry.turn_id.clone();
let mut entries = self.entries.lock().await;
entries.insert(turn_id.clone(), entry);
turn_id
}
/// Look up a turn by id. Returns None if not found or expired.
pub async fn get(&self, turn_id: &str) -> Option<Arc<TurnEntry>> {
let entries = self.entries.lock().await;
entries.get(turn_id).cloned()
}
/// Clean up stale entries older than the timeout. Returns the count of
/// entries removed.
pub async fn cleanup_stale(&self) -> usize {
let mut entries = self.entries.lock().await;
let _now = Instant::now();
let stale: Vec<String> = entries
.iter()
.filter(|(_, entry)| entry.created_at.elapsed().as_secs() > self.timeout_secs)
.map(|(id, _)| id.clone())
.collect();
for id in &stale {
entries.remove(id);
}
if !stale.is_empty() {
log::info!(
"TurnRegistry: cleaned up {} stale entries (timeout={}s)",
stale.len(),
self.timeout_secs
);
}
stale.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai::insight_chat::ChatStreamEvent;
use std::time::Duration;
/// Unwrap the events from a `ReplayOutcome::Events`, panicking otherwise.
fn events_of(outcome: ReplayOutcome) -> Vec<ChatStreamEvent> {
match outcome {
ReplayOutcome::Events { events, .. } => events,
other => panic!("expected Events, got {other:?}"),
}
}
// ── TurnStatus ──────────────────────────────────────────────────
#[test]
fn turn_status_from_u32_valid_values() {
assert_eq!(TurnStatus::from(0), TurnStatus::Running);
assert_eq!(TurnStatus::from(1), TurnStatus::Done);
assert_eq!(TurnStatus::from(2), TurnStatus::Error);
assert_eq!(TurnStatus::from(3), TurnStatus::Cancelled);
}
#[test]
fn turn_status_from_u32_unknown_defaults_to_running() {
assert_eq!(TurnStatus::from(4), TurnStatus::Running);
assert_eq!(TurnStatus::from(u32::MAX), TurnStatus::Running);
}
#[test]
fn turn_status_as_str() {
assert_eq!(TurnStatus::Running.as_str(), "running");
assert_eq!(TurnStatus::Done.as_str(), "done");
assert_eq!(TurnStatus::Error.as_str(), "error");
assert_eq!(TurnStatus::Cancelled.as_str(), "cancelled");
}
// ── TurnEntry ───────────────────────────────────────────────────
#[tokio::test]
async fn turn_entry_push_and_replay() {
let entry = Arc::new(TurnEntry::new(
"t1".to_string(),
"/photo.jpg".to_string(),
1,
));
entry
.push_event(ChatStreamEvent::TextDelta("hello".to_string()))
.await;
entry
.push_event(ChatStreamEvent::TextDelta(" world".to_string()))
.await;
let events = events_of(entry.replay_from(0).await);
assert_eq!(events.len(), 2);
}
#[tokio::test]
async fn turn_entry_replay_with_skip() {
let entry = Arc::new(TurnEntry::new(
"t1".to_string(),
"/photo.jpg".to_string(),
1,
));
for i in 0..5 {
entry
.push_event(ChatStreamEvent::TextDelta(format!("e{i}")))
.await;
}
// skip_before=0 → all 5 events
let all = events_of(entry.replay_from(0).await);
assert_eq!(all.len(), 5);
// skip_before=2 → events 2,3,4 (3 events)
let skipped = events_of(entry.replay_from(2).await);
assert_eq!(skipped.len(), 3);
// skip_before=5 → caught up to the live edge (not Gone).
assert!(matches!(
entry.replay_from(5).await,
ReplayOutcome::CaughtUp { next_skip: 5 }
));
}
#[tokio::test]
async fn turn_entry_replay_empty_by_default() {
let entry = Arc::new(TurnEntry::new(
"t1".to_string(),
"/photo.jpg".to_string(),
1,
));
// Empty buffer with skip_before=0 → caught up (nothing to replay yet).
assert!(matches!(
entry.replay_from(0).await,
ReplayOutcome::CaughtUp { next_skip: 0 }
));
}
#[tokio::test]
async fn turn_entry_is_running_initially() {
let entry = TurnEntry::new("t1".to_string(), "/photo.jpg".to_string(), 1);
assert!(entry.is_running());
}
#[tokio::test]
async fn turn_entry_set_terminal_status() {
let entry = Arc::new(TurnEntry::new(
"t1".to_string(),
"/photo.jpg".to_string(),
1,
));
assert!(entry.is_running());
entry.set_terminal_status(TurnStatus::Done);
assert!(!entry.is_running());
}
#[tokio::test]
async fn turn_entry_info() {
let entry = Arc::new(TurnEntry::new(
"t1".to_string(),
"/photo.jpg".to_string(),
42,
));
entry
.push_event(ChatStreamEvent::TextDelta("x".to_string()))
.await;
entry.set_terminal_status(TurnStatus::Done);
let info = entry.info().await;
assert_eq!(info.turn_id, "t1");
assert_eq!(info.file_path, "/photo.jpg");
assert_eq!(info.library_id, 42);
assert_eq!(info.status, TurnStatus::Done);
assert_eq!(info.total_events_pushed, 1);
assert_eq!(info.buffered_count, 1);
}
#[tokio::test]
async fn turn_entry_eviction_caps_buffer() {
let entry = Arc::new(TurnEntry::new(
"t1".to_string(),
"/photo.jpg".to_string(),
1,
));
// Push MAX_BUFFERED_EVENTS + 10 events.
for i in 0..(MAX_BUFFERED_EVENTS + 10) {
entry
.push_event(ChatStreamEvent::TextDelta(format!("e{i}")))
.await;
}
// Asking from absolute 0 after eviction is Gone (0-9 were dropped).
assert!(matches!(entry.replay_from(0).await, ReplayOutcome::Gone));
// Reading from the new base (10) returns the full capped buffer.
let events = events_of(entry.replay_from(10).await);
assert_eq!(events.len(), MAX_BUFFERED_EVENTS);
// First event should be at index 10 (0-9 were evicted).
if let ChatStreamEvent::TextDelta(s) = &events[0] {
assert_eq!(s, "e10");
} else {
panic!("expected TextDelta");
}
// Last event should be at index MAX_BUFFERED_EVENTS + 9.
if let ChatStreamEvent::TextDelta(s) = &events[events.len() - 1] {
assert_eq!(s, &format!("e{}", MAX_BUFFERED_EVENTS + 9));
} else {
panic!("expected TextDelta");
}
}
#[tokio::test]
async fn turn_entry_replay_evicted_index_is_gone() {
let entry = Arc::new(TurnEntry::new(
"t1".to_string(),
"/photo.jpg".to_string(),
1,
));
// Push one past the cap so exactly one event (index 0) is evicted.
for i in 0..=MAX_BUFFERED_EVENTS {
entry
.push_event(ChatStreamEvent::TextDelta(format!("e{i}")))
.await;
}
// Base is now 1; asking from absolute 0 is evicted territory → Gone.
assert!(matches!(entry.replay_from(0).await, ReplayOutcome::Gone));
// skip_before = MAX_BUFFERED_EVENTS → last event only (index valid).
let last = events_of(entry.replay_from(MAX_BUFFERED_EVENTS as u32).await);
assert_eq!(last.len(), 1);
// skip_before = MAX_BUFFERED_EVENTS + 1 → caught up to the live edge.
assert!(matches!(
entry.replay_from((MAX_BUFFERED_EVENTS + 1) as u32).await,
ReplayOutcome::CaughtUp { .. }
));
}
// ── TurnRegistry ────────────────────────────────────────────────
#[tokio::test]
async fn turn_registry_insert_and_get() {
let registry = TurnRegistry::new(300);
let entry = Arc::new(TurnEntry::new(
"t1".to_string(),
"/photo.jpg".to_string(),
1,
));
let id = registry.insert(entry).await;
assert_eq!(id, "t1");
let retrieved = registry.get("t1").await;
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().turn_id, "t1");
}
#[tokio::test]
async fn turn_registry_get_nonexistent_returns_none() {
let registry = TurnRegistry::new(300);
assert!(registry.get("nonexistent").await.is_none());
}
#[tokio::test]
async fn turn_registry_cleanup_stale_removes_old_entries() {
let registry = TurnRegistry::new(0);
let mut entry = TurnEntry::new("t1".to_string(), "/photo.jpg".to_string(), 1);
entry.created_at = Instant::now() - Duration::from_secs(1);
registry.insert(Arc::new(entry)).await;
let cleaned = registry.cleanup_stale().await;
assert_eq!(cleaned, 1);
assert!(registry.get("t1").await.is_none());
}
#[tokio::test]
async fn turn_registry_cleanup_stale_preserves_recent() {
let registry = TurnRegistry::new(3600); // 1 hour
let entry = Arc::new(TurnEntry::new(
"t1".to_string(),
"/photo.jpg".to_string(),
1,
));
registry.insert(entry).await;
let cleaned = registry.cleanup_stale().await;
assert_eq!(cleaned, 0);
assert!(registry.get("t1").await.is_some());
}
#[tokio::test]
async fn turn_registry_cleanup_stale_multiple() {
let registry = TurnRegistry::new(0);
for i in 0..5 {
let mut entry = TurnEntry::new(format!("t{i}"), "/photo.jpg".to_string(), 1);
entry.created_at = Instant::now() - Duration::from_secs(1);
registry.insert(Arc::new(entry)).await;
}
let cleaned = registry.cleanup_stale().await;
assert_eq!(cleaned, 5);
}
#[tokio::test]
async fn turn_registry_timeout_secs() {
let registry = TurnRegistry::new(600);
assert_eq!(registry.timeout_secs(), 600);
}
// ── next_batch / live replay ────────────────────────────────────
/// Drain a turn the way the SSE replay handler does: pull batches via
/// `next_batch` until the turn is finished and fully drained.
async fn drain_to_end(entry: Arc<TurnEntry>) -> Vec<ChatStreamEvent> {
let mut out = Vec::new();
let mut skip = 0u32;
while let ReplayOutcome::Events { events, next_skip } = entry.next_batch(skip).await {
out.extend(events);
skip = next_skip;
}
out
}
fn is_terminal(ev: &ChatStreamEvent) -> bool {
matches!(ev, ChatStreamEvent::Done { .. } | ChatStreamEvent::Error(_))
}
/// The core guarantee behind the replay rewrite: a reader waiting on
/// `next_batch` always receives the terminal event, even though the
/// writer flips status to terminal immediately after pushing it.
#[tokio::test]
async fn next_batch_always_delivers_terminal_event() {
for _ in 0..50 {
let entry = Arc::new(TurnEntry::new("t".into(), "/p.jpg".into(), 1));
let writer = entry.clone();
let w = tokio::spawn(async move {
writer
.push_event(ChatStreamEvent::IterationStart { n: 1, max: 6 })
.await;
writer
.push_event(ChatStreamEvent::TextDelta("hi".into()))
.await;
// Push terminal then flip status with no await between — the
// race that previously dropped the Done on the reader side.
writer
.push_event(ChatStreamEvent::Done {
tool_calls_made: 0,
iterations_used: 1,
truncated: false,
prompt_tokens: None,
eval_tokens: None,
num_ctx: None,
amended_insight_id: None,
backend_used: "local".into(),
model_used: "m".into(),
cancelled: false,
})
.await;
writer.set_terminal_status(TurnStatus::Done);
});
let events = drain_to_end(entry).await;
w.await.unwrap();
assert!(
events.last().is_some_and(is_terminal),
"terminal event missing; got {} events",
events.len()
);
assert_eq!(events.len(), 3, "expected IterationStart, TextDelta, Done");
}
}
/// A reader that connects before any event is pushed blocks in
/// `next_batch` and then receives events as the writer produces them.
#[tokio::test]
async fn next_batch_waits_for_late_events() {
let entry = Arc::new(TurnEntry::new("t".into(), "/p.jpg".into(), 1));
let writer = entry.clone();
tokio::spawn(async move {
tokio::task::yield_now().await;
writer
.push_event(ChatStreamEvent::TextDelta("late".into()))
.await;
writer.set_terminal_status(TurnStatus::Done);
});
// First call blocks until the writer pushes, rather than returning
// CaughtUp on the empty buffer of a running turn.
match entry.next_batch(0).await {
ReplayOutcome::Events { events, next_skip } => {
assert_eq!(events.len(), 1);
assert_eq!(next_skip, 1);
}
other => panic!("expected Events, got {other:?}"),
}
}
#[tokio::test]
async fn next_batch_closes_on_terminal_when_caught_up() {
let entry = Arc::new(TurnEntry::new("t".into(), "/p.jpg".into(), 1));
entry
.push_event(ChatStreamEvent::TextDelta("x".into()))
.await;
entry.set_terminal_status(TurnStatus::Done);
// Caught up (skip past the one buffered event) on a finished turn →
// CaughtUp so the handler closes the stream rather than hanging.
assert!(matches!(
entry.next_batch(1).await,
ReplayOutcome::CaughtUp { .. }
));
}
#[tokio::test]
async fn next_batch_reports_gone_for_evicted_index() {
let entry = Arc::new(TurnEntry::new("t".into(), "/p.jpg".into(), 1));
for i in 0..=MAX_BUFFERED_EVENTS {
entry
.push_event(ChatStreamEvent::TextDelta(format!("e{i}")))
.await;
}
// Index 0 was evicted (base advanced to 1).
assert!(matches!(entry.next_batch(0).await, ReplayOutcome::Gone));
}
// ── abort handle (#1 cancellation) ──────────────────────────────
#[tokio::test]
async fn abort_handle_aborts_task_once() {
let entry = Arc::new(TurnEntry::new("t".into(), "/p.jpg".into(), 1));
// No handle installed yet → abort is a no-op.
assert!(!entry.abort());
let handle = tokio::spawn(async {
// Long-lived task that only ends via abort.
futures::future::pending::<()>().await;
});
entry.set_abort_handle(handle.abort_handle());
assert!(entry.abort(), "first abort should fire");
assert!(!entry.abort(), "handle is taken; second abort is a no-op");
// The aborted task resolves to a cancellation JoinError.
let join = handle.await;
assert!(join.unwrap_err().is_cancelled());
}
#[tokio::test]
async fn base_index_tracks_eviction() {
let entry = Arc::new(TurnEntry::new("t".into(), "/p.jpg".into(), 1));
for i in 0..(MAX_BUFFERED_EVENTS + 5) {
entry
.push_event(ChatStreamEvent::TextDelta(format!("e{i}")))
.await;
}
let info = entry.info().await;
// 5 events evicted; total keeps climbing, buffer stays capped.
assert_eq!(info.total_events_pushed, (MAX_BUFFERED_EVENTS + 5) as u32);
assert_eq!(info.buffered_count, MAX_BUFFERED_EVENTS as u32);
// First live index is 5: reading from there yields the full buffer.
let from_base = events_of(entry.replay_from(5).await);
assert_eq!(from_base.len(), MAX_BUFFERED_EVENTS);
}
}
+796
View File
@@ -0,0 +1,796 @@
//! Per-tick drains the watcher runs alongside ingest.
//!
//! These passes were previously inlined in `main.rs`; they exist because
//! a quick scan only walks recently-modified files, so any backlog of
//! rows missing a `content_hash` / `date_taken` / face detection
//! wouldn't otherwise drain except during the once-an-hour full scan.
//! Each function is bounded per call by a `*_PER_TICK` env-var cap.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use log::{debug, info, warn};
use crate::content_hash;
use crate::database::ExifDao;
use crate::date_resolver;
use crate::face_watch;
use crate::faces;
use crate::file_types;
use crate::libraries;
use crate::tags;
/// Compute and persist content_hash for image_exif rows where it's NULL.
///
/// Bounded per call by `FACE_HASH_BACKFILL_MAX_PER_TICK` (default 2000)
/// so a watcher tick on a large legacy library doesn't block for hours
/// blake3-ing every photo at once. Subsequent scans pick up the rest.
/// For 50k+ libraries the dedicated `cargo run --bin backfill_hashes`
/// is still faster (it doesn't fight a watcher loop for the DAO mutex).
///
/// Drains unhashed image_exif rows by querying them directly, independent
/// of the filesystem walk. Quick scans only walk recently-modified files,
/// so a backlog of pre-existing unhashed rows never enters
/// `process_new_files`'s candidate set — left alone, it would only drain
/// on full scans (default once an hour). Calling this every tick keeps
/// the face-detection backlog moving regardless.
///
/// Returns the number of rows successfully backfilled this pass.
pub fn backfill_unhashed_backlog(
context: &opentelemetry::Context,
library: &libraries::Library,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
) -> usize {
let cap: i64 = dotenv::var("FACE_HASH_BACKFILL_MAX_PER_TICK")
.ok()
.and_then(|s| s.parse().ok())
.filter(|n: &i64| *n > 0)
.unwrap_or(2000);
// Fetch up to cap+1 rows so we can tell "more remain" without a
// separate count query. Across libraries — there's no per-library
// filter on get_rows_missing_hash today — but we only ever update
// rows whose library_id matches the caller's library, so other
// libraries' rows just get skipped here and picked up on the next
// library's tick. Negligible cost given the cap.
let rows: Vec<(i32, String)> = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
dao.get_rows_missing_hash(context, cap + 1)
.unwrap_or_default()
};
if rows.is_empty() {
return 0;
}
let more_than_cap = rows.len() as i64 > cap;
let base_path = std::path::Path::new(&library.root_path);
let mut backfilled = 0usize;
let mut errors = 0usize;
let mut skipped_other_lib = 0usize;
for (lib_id, rel_path) in rows.iter().take(cap as usize) {
if *lib_id != library.id {
skipped_other_lib += 1;
continue;
}
let abs = base_path.join(rel_path);
if !abs.exists() {
// File walked away — the watcher's reconciliation pass will
// remove the orphan exif row eventually.
continue;
}
match content_hash::compute(&abs) {
Ok(id) => {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
if let Err(e) = dao.backfill_content_hash(
context,
library.id,
rel_path,
&id.content_hash,
id.size_bytes,
) {
warn!(
"face_watch: backfill_content_hash failed for {}: {:?}",
rel_path, e
);
errors += 1;
} else {
backfilled += 1;
}
}
Err(e) => {
debug!(
"face_watch: hash compute failed for {} ({:?})",
abs.display(),
e
);
errors += 1;
}
}
}
if backfilled > 0 || errors > 0 || more_than_cap {
info!(
"face_watch: backfill pass for library '{}': hashed {} ({} error(s), {} skipped to other libraries; {} cap, more_remain={})",
library.name, backfilled, errors, skipped_other_lib, cap, more_than_cap
);
}
backfilled
}
/// Drain image_exif rows whose `date_taken` was never resolved or was
/// resolved by the weakest fallback (`fs_time`). Runs the canonical-date
/// waterfall — exiftool batch (one subprocess for the whole tick's
/// rows) → filename regex → earliest_fs_time — and persists each
/// resolution with its source tag. Capped per tick by
/// `DATE_BACKFILL_MAX_PER_TICK` (default 500) so a 14k-row library
/// drains over a few quick-scan ticks without blocking the watcher.
///
/// kamadak-exif is intentionally skipped here: the row already has a
/// NULL date_taken because the ingest path's kamadak-exif call returned
/// nothing, and re-running it would just produce the same answer.
/// exiftool is the meaningful new attempt — it handles videos and
/// MakerNote-hosted dates kamadak can't reach.
pub fn backfill_missing_date_taken(
context: &opentelemetry::Context,
library: &libraries::Library,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
) -> usize {
let cap: i64 = dotenv::var("DATE_BACKFILL_MAX_PER_TICK")
.ok()
.and_then(|s| s.parse().ok())
.filter(|n: &i64| *n > 0)
.unwrap_or(500);
let rows: Vec<(i32, String)> = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
dao.get_rows_needing_date_backfill(context, library.id, cap + 1)
.unwrap_or_default()
};
if rows.is_empty() {
return 0;
}
let more_than_cap = rows.len() as i64 > cap;
let base_path = std::path::Path::new(&library.root_path);
// Build absolute paths and drop rows whose files no longer exist —
// the missing-file scan in library_maintenance retires deleted rows
// separately. Without this filter, NULL-date rows for missing files
// would loop through the drain forever (no source can resolve them).
let mut existing: Vec<(String, PathBuf)> = Vec::with_capacity(rows.len());
for (_, rel_path) in rows.iter().take(cap as usize) {
let abs = base_path.join(rel_path);
if abs.exists() {
existing.push((rel_path.clone(), abs));
}
}
if existing.is_empty() {
return 0;
}
// One exiftool subprocess for the whole batch; the resolver falls
// through to filename / fs_time per file when exiftool can't supply
// a date (or isn't installed at all).
let paths: Vec<PathBuf> = existing.iter().map(|(_, p)| p.clone()).collect();
let resolved = date_resolver::resolve_dates_batch(&paths, &HashMap::new());
let mut backfilled = 0usize;
let mut unresolved = 0usize;
let mut by_source: HashMap<&'static str, usize> = HashMap::new();
{
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
for (rel_path, abs) in &existing {
let Some(rd) = resolved.get(abs).copied() else {
unresolved += 1;
continue;
};
match dao.backfill_date_taken(
context,
library.id,
rel_path,
rd.timestamp,
rd.source.as_str(),
) {
Ok(()) => {
backfilled += 1;
*by_source.entry(rd.source.as_str()).or_insert(0) += 1;
}
Err(e) => {
warn!(
"date_backfill: update failed for lib {} {}: {:?}",
library.id, rel_path, e
);
}
}
}
}
if backfilled > 0 || unresolved > 0 || more_than_cap {
info!(
"date_backfill: library '{}': resolved {} ({:?}), {} unresolved, cap={}, more_remain={}",
library.name, backfilled, by_source, unresolved, cap, more_than_cap
);
}
backfilled
}
/// Per-tick face-detection drain. Pulls a capped batch of hashed-but-
/// unscanned image_exif rows directly via the FaceDao anti-join and
/// hands them to the existing detection pass. Runs on every tick (not
/// just full scans) so the backlog moves at quick-scan cadence.
/// Per-tick CLIP encoding drain. Mirrors `process_face_backlog`: pull
/// up to `CLIP_BACKLOG_MAX_PER_TICK` candidates with a known
/// `content_hash` but no `clip_embedding`, hand them to
/// `clip_watch::run_clip_encoding_pass` for parallel fan-out, and let
/// that module write the result back via `backfill_clip_embedding`.
///
/// Idempotent — a row stays in the candidate set until its embedding
/// lands, so a transient failure (Apollo unreachable, CUDA OOM) just
/// defers to the next tick. Permanent failures (un-decodable bytes)
/// retry every tick at this point; future Branch may add a status
/// column like face_detections has.
pub fn process_clip_backlog(
context: &opentelemetry::Context,
library: &libraries::Library,
clip_client: &crate::ai::clip_client::ClipClient,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
excluded_dirs: &[String],
) {
if !clip_client.is_enabled() {
return;
}
let cap: i64 = dotenv::var("CLIP_BACKLOG_MAX_PER_TICK")
.ok()
.and_then(|s| s.parse().ok())
.filter(|n: &i64| *n > 0)
.unwrap_or(32);
let rows: Vec<(String, String)> = {
let mut dao = exif_dao.lock().expect("exif dao");
match dao.list_clip_unencoded_candidates(context, library.id, cap) {
Ok(r) => r,
Err(e) => {
warn!(
"clip_watch: list_clip_unencoded_candidates failed for library '{}': {:?}",
library.name, e
);
return;
}
}
};
if rows.is_empty() {
return;
}
info!(
"clip_watch: backlog drain — encoding {} candidate(s) for library '{}' (cap={})",
rows.len(),
library.name,
cap
);
let candidates: Vec<crate::clip_watch::ClipCandidate> = rows
.into_iter()
.map(
|(rel_path, content_hash)| crate::clip_watch::ClipCandidate {
rel_path,
content_hash,
},
)
.collect();
crate::clip_watch::run_clip_encoding_pass(
library,
excluded_dirs,
clip_client,
Arc::clone(exif_dao),
candidates,
);
}
pub fn process_face_backlog(
context: &opentelemetry::Context,
library: &libraries::Library,
face_client: &crate::ai::face_client::FaceClient,
face_dao: &Arc<Mutex<Box<dyn faces::FaceDao>>>,
tag_dao: &Arc<Mutex<Box<dyn tags::TagDao>>>,
excluded_dirs: &[String],
) {
let cap: i64 = dotenv::var("FACE_BACKLOG_MAX_PER_TICK")
.ok()
.and_then(|s| s.parse().ok())
.filter(|n: &i64| *n > 0)
.unwrap_or(64);
let rows: Vec<(String, String)> = {
let mut dao = face_dao.lock().expect("face dao");
match dao.list_unscanned_candidates(context, library.id, cap) {
Ok(r) => r,
Err(e) => {
warn!(
"face_watch: list_unscanned_candidates failed for library '{}': {:?}",
library.name, e
);
return;
}
}
};
if rows.is_empty() {
return;
}
info!(
"face_watch: backlog drain — running detection on {} candidate(s) for library '{}' (cap={})",
rows.len(),
library.name,
cap
);
let candidates: Vec<face_watch::FaceCandidate> = rows
.into_iter()
.map(|(rel_path, content_hash)| face_watch::FaceCandidate {
rel_path,
content_hash,
})
.collect();
face_watch::run_face_detection_pass(
library,
excluded_dirs,
face_client,
Arc::clone(face_dao),
Arc::clone(tag_dao),
candidates,
);
}
/// Compute content_hash for any image rows the walker just touched
/// whose stored EXIF row is still hash-less. Called from
/// `process_new_files` so freshly-ingested files don't have to wait for
/// the next standalone `backfill_unhashed_backlog` tick before face
/// detection can key on their bytes.
///
/// Cap is on **successes only**. An earlier version counted errors too,
/// so a pocket of chronically-unhashable files at the front of the
/// table (vanished mid-scan, permission denied, etc.) burned the budget
/// every tick and the rest of the backlog never advanced.
pub fn backfill_missing_content_hashes(
context: &opentelemetry::Context,
files: &[(PathBuf, String)],
library: &libraries::Library,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
) {
let image_paths: Vec<String> = files
.iter()
.filter(|(p, _)| !file_types::is_video_file(p))
.map(|(_, rel)| rel.clone())
.collect();
if image_paths.is_empty() {
return;
}
let exif_records = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
dao.get_exif_batch(context, Some(library.id), &image_paths)
.unwrap_or_default()
};
// Cheap lookup back from rel_path → absolute file_path so
// content_hash::compute can read the bytes.
let path_by_rel: HashMap<String, &PathBuf> =
files.iter().map(|(p, rel)| (rel.clone(), p)).collect();
let cap: usize = dotenv::var("FACE_HASH_BACKFILL_MAX_PER_TICK")
.ok()
.and_then(|s| s.parse().ok())
.filter(|n: &usize| *n > 0)
.unwrap_or(2000);
// Count the unhashed backlog up front so we can surface "still needs
// backfill: N" in the log — without it, a face-scan that's stuck at
// 44% looks stalled when really it's chipping through hashes.
let unhashed_total = exif_records
.iter()
.filter(|r| r.content_hash.is_none())
.count();
let mut backfilled = 0usize;
let mut errors = 0usize;
for record in &exif_records {
if backfilled >= cap {
break;
}
if record.content_hash.is_some() {
continue;
}
let Some(file_path) = path_by_rel.get(&record.file_path) else {
// Walked file went missing between the directory scan and now;
// next tick will retry naturally.
continue;
};
match content_hash::compute(file_path) {
Ok(id) => {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
if let Err(e) = dao.backfill_content_hash(
context,
library.id,
&record.file_path,
&id.content_hash,
id.size_bytes,
) {
warn!(
"face_watch: backfill_content_hash failed for {}: {:?}",
record.file_path, e
);
errors += 1;
} else {
backfilled += 1;
}
}
Err(e) => {
debug!(
"face_watch: hash compute failed for {} ({:?})",
file_path.display(),
e
);
errors += 1;
}
}
}
// Always log when there's an unhashed backlog so an operator
// looking at "scan stuck at 44%" can see backfill is running and
// how much remains. Quiet only when there's nothing to do.
if unhashed_total > 0 || backfilled > 0 || errors > 0 {
let remaining = unhashed_total.saturating_sub(backfilled);
info!(
"face_watch: backfilled {}/{} content_hash for library '{}' ({} error(s); {} still need backfill; cap={})",
backfilled, unhashed_total, library.name, errors, remaining, cap
);
}
}
/// Build the face-detection candidate list for a scan tick.
///
/// Returns `(rel_path, content_hash)` for every image file that has a
/// content_hash recorded in image_exif but no row in face_detections
/// yet. Re-querying image_exif here picks up rows the EXIF write loop
/// just inserted alongside any pre-existing rows the watcher walked
/// over — covers both new uploads and the initial backlog scan.
pub fn build_face_candidates(
context: &opentelemetry::Context,
library: &libraries::Library,
files: &[(PathBuf, String)],
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
face_dao: &Arc<Mutex<Box<dyn faces::FaceDao>>>,
) -> Vec<face_watch::FaceCandidate> {
// Restrict to image files; videos aren't face-scanned in v1 (kamadak
// doesn't even register them in image_exif).
let image_paths: Vec<String> = files
.iter()
.filter(|(p, _)| !file_types::is_video_file(p))
.map(|(_, rel)| rel.clone())
.collect();
if image_paths.is_empty() {
return Vec::new();
}
let exif_records = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
dao.get_exif_batch(context, Some(library.id), &image_paths)
.unwrap_or_default()
};
// rel_path → content_hash (only rows with a hash; without one we have
// nothing to key face data against).
let mut hash_by_path: HashMap<String, String> = HashMap::with_capacity(exif_records.len());
for record in exif_records {
if let Some(h) = record.content_hash {
hash_by_path.insert(record.file_path, h);
}
}
let mut candidates = Vec::new();
let mut dao = face_dao.lock().expect("face dao");
for rel_path in image_paths {
let Some(hash) = hash_by_path.get(&rel_path) else {
continue;
};
match dao.already_scanned(context, hash) {
Ok(true) => continue,
Ok(false) => candidates.push(face_watch::FaceCandidate {
rel_path,
content_hash: hash.clone(),
}),
Err(e) => {
warn!("face_watch: already_scanned errored for {}: {:?}", hash, e);
}
}
}
candidates
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::{Arc, Mutex};
use diesel::prelude::*;
use tempfile::TempDir;
use crate::database::models::{InsertImageExif, InsertLibrary};
use crate::database::test::in_memory_db_connection;
use crate::database::{ExifDao, SqliteExifDao, schema};
use crate::faces::{FaceDao, SqliteFaceDao};
use crate::libraries::Library;
fn ctx() -> opentelemetry::Context {
opentelemetry::Context::new()
}
/// Everything `setup` hands back to a test: tempdir, library, shared
/// connection, and the two DAOs. Aliased to keep clippy's
/// type-complexity lint satisfied.
type SetupFixture = (
TempDir,
Library,
Arc<Mutex<diesel::SqliteConnection>>,
Arc<Mutex<Box<dyn ExifDao>>>,
Arc<Mutex<Box<dyn FaceDao>>>,
);
/// Build a tempdir-backed library + DAOs sharing a single in-memory
/// SQLite connection (so cross-table joins like
/// `list_unscanned_candidates` see consistent state).
fn setup() -> SetupFixture {
let tmp = TempDir::new().expect("tempdir");
let mut conn = in_memory_db_connection();
// Migration seeds library id=1 with a placeholder root; rewrite it
// to point at the tempdir so `<root>/<rel_path>` resolves to real
// files this test creates.
diesel::update(schema::libraries::table.filter(schema::libraries::id.eq(1)))
.set(schema::libraries::root_path.eq(tmp.path().to_string_lossy().to_string()))
.execute(&mut conn)
.expect("rewrite library 1 root");
// Add a second library so cross-library skip cases have somewhere
// to put their rows.
diesel::insert_into(schema::libraries::table)
.values(InsertLibrary {
name: "other",
root_path: "/tmp/other-test-lib",
created_at: 0,
enabled: true,
excluded_dirs: None,
})
.execute(&mut conn)
.expect("seed second library");
let library = Library {
id: 1,
name: "main".to_string(),
root_path: tmp.path().to_string_lossy().to_string(),
enabled: true,
excluded_dirs: Vec::new(),
};
let shared = Arc::new(Mutex::new(conn));
let exif_dao: Arc<Mutex<Box<dyn ExifDao>>> = Arc::new(Mutex::new(Box::new(
SqliteExifDao::from_shared(Arc::clone(&shared)),
)));
let face_dao: Arc<Mutex<Box<dyn FaceDao>>> = Arc::new(Mutex::new(Box::new(
SqliteFaceDao::from_connection(Arc::clone(&shared)),
)));
(tmp, library, shared, exif_dao, face_dao)
}
fn insert_exif(
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
lib_id: i32,
rel: &str,
content_hash: Option<&str>,
) {
let mut dao = exif_dao.lock().unwrap();
dao.store_exif(
&ctx(),
InsertImageExif {
library_id: lib_id,
file_path: rel.to_string(),
camera_make: None,
camera_model: None,
lens_model: None,
width: None,
height: None,
orientation: None,
gps_latitude: None,
gps_longitude: None,
gps_altitude: None,
focal_length: None,
aperture: None,
shutter_speed: None,
iso: None,
date_taken: None,
created_time: 0,
last_modified: 0,
content_hash: content_hash.map(|s| s.to_string()),
size_bytes: None,
phash_64: None,
dhash_64: None,
date_taken_source: None,
},
)
.expect("insert");
}
fn write_image(root: &std::path::Path, rel: &str, bytes: &[u8]) {
let abs = root.join(rel);
if let Some(parent) = abs.parent() {
fs::create_dir_all(parent).expect("mkdir");
}
fs::write(abs, bytes).expect("write file");
}
#[test]
fn backfill_unhashed_backlog_hashes_missing_rows_in_this_library() {
let (tmp, library, _conn, exif_dao, _face_dao) = setup();
write_image(tmp.path(), "a.jpg", b"alpha-bytes");
write_image(tmp.path(), "b.jpg", b"bravo-bytes");
insert_exif(&exif_dao, 1, "a.jpg", None);
insert_exif(&exif_dao, 1, "b.jpg", None);
let backfilled = backfill_unhashed_backlog(&ctx(), &library, &exif_dao);
assert_eq!(backfilled, 2);
let mut dao = exif_dao.lock().unwrap();
let rows = dao
.get_exif_batch(&ctx(), Some(1), &["a.jpg".to_string(), "b.jpg".to_string()])
.unwrap();
assert_eq!(rows.len(), 2);
for r in rows {
assert!(
r.content_hash.is_some(),
"row {} should have a hash",
r.file_path
);
}
}
#[test]
fn backfill_unhashed_backlog_skips_other_libraries_and_missing_files() {
let (tmp, library, _conn, exif_dao, _face_dao) = setup();
write_image(tmp.path(), "exists.jpg", b"hello");
// Row for this library whose file is missing on disk:
insert_exif(&exif_dao, 1, "ghost.jpg", None);
insert_exif(&exif_dao, 1, "exists.jpg", None);
// Row in the other library — must be skipped (different lib_id).
insert_exif(&exif_dao, 2, "other.jpg", None);
let backfilled = backfill_unhashed_backlog(&ctx(), &library, &exif_dao);
assert_eq!(backfilled, 1, "only the existing in-library file hashes");
let mut dao = exif_dao.lock().unwrap();
let other = dao
.get_exif_batch(&ctx(), Some(2), &["other.jpg".to_string()])
.unwrap();
assert_eq!(other.len(), 1);
assert!(
other[0].content_hash.is_none(),
"other-library row must remain unhashed"
);
let ghost = dao
.get_exif_batch(&ctx(), Some(1), &["ghost.jpg".to_string()])
.unwrap();
assert_eq!(ghost.len(), 1);
assert!(
ghost[0].content_hash.is_none(),
"missing-on-disk row stays unhashed (reconciliation removes it later)"
);
}
#[test]
fn backfill_unhashed_backlog_respects_per_tick_cap() {
// Env-var-driven cap; the function reads it on every call, so we
// can set it just for this test and unset before returning.
// Serial guard: tests in the same binary may share env, but each
// backfill call re-reads — and we only care that the cap shape
// (success count <= cap, more_remain logged) holds.
unsafe {
std::env::set_var("FACE_HASH_BACKFILL_MAX_PER_TICK", "2");
}
let (tmp, library, _conn, exif_dao, _face_dao) = setup();
for i in 0..5 {
let rel = format!("img_{}.jpg", i);
write_image(tmp.path(), &rel, format!("bytes-{}", i).as_bytes());
insert_exif(&exif_dao, 1, &rel, None);
}
let backfilled = backfill_unhashed_backlog(&ctx(), &library, &exif_dao);
assert_eq!(backfilled, 2, "cap=2 must bound the per-tick successes");
unsafe {
std::env::remove_var("FACE_HASH_BACKFILL_MAX_PER_TICK");
}
}
#[test]
fn backfill_missing_content_hashes_skips_videos_and_hashed_rows() {
let (tmp, library, _conn, exif_dao, _face_dao) = setup();
// Two image rows (one already hashed, one not), one video.
write_image(tmp.path(), "fresh.jpg", b"fresh-pixels");
write_image(tmp.path(), "already.jpg", b"already-pixels");
write_image(tmp.path(), "clip.mp4", b"video-bytes");
insert_exif(&exif_dao, 1, "fresh.jpg", None);
insert_exif(&exif_dao, 1, "already.jpg", Some("pre-existing-hash"));
insert_exif(&exif_dao, 1, "clip.mp4", None);
let files: Vec<(PathBuf, String)> = vec![
(tmp.path().join("fresh.jpg"), "fresh.jpg".to_string()),
(tmp.path().join("already.jpg"), "already.jpg".to_string()),
(tmp.path().join("clip.mp4"), "clip.mp4".to_string()),
];
backfill_missing_content_hashes(&ctx(), &files, &library, &exif_dao);
let mut dao = exif_dao.lock().unwrap();
let rows = dao
.get_exif_batch(
&ctx(),
Some(1),
&[
"fresh.jpg".to_string(),
"already.jpg".to_string(),
"clip.mp4".to_string(),
],
)
.unwrap();
let by_path: HashMap<String, Option<String>> = rows
.into_iter()
.map(|r| (r.file_path, r.content_hash))
.collect();
assert!(
by_path["fresh.jpg"].is_some(),
"fresh image must get a hash"
);
assert_eq!(
by_path["already.jpg"].as_deref(),
Some("pre-existing-hash"),
"already-hashed image left untouched"
);
assert!(
by_path["clip.mp4"].is_none(),
"video skipped (not face-scanned, no hash needed via this path)"
);
}
#[test]
fn build_face_candidates_filters_videos_unhashed_and_already_scanned() {
let (tmp, library, _conn, exif_dao, face_dao) = setup();
// Seed image_exif with: hashed unscanned, hashed scanned, unhashed,
// and a video. Files don't need to exist on disk — the function
// doesn't read them, only the DB rows.
insert_exif(&exif_dao, 1, "fresh.jpg", Some("hash-fresh"));
insert_exif(&exif_dao, 1, "scanned.jpg", Some("hash-scanned"));
insert_exif(&exif_dao, 1, "unhashed.jpg", None);
insert_exif(&exif_dao, 1, "clip.mp4", Some("hash-video"));
// Mark `scanned.jpg`'s hash as already detected.
{
let mut dao = face_dao.lock().unwrap();
dao.mark_status(&ctx(), 1, "hash-scanned", "scanned.jpg", "no_faces", "test")
.expect("mark scanned");
}
let files: Vec<(PathBuf, String)> = vec![
(tmp.path().join("fresh.jpg"), "fresh.jpg".to_string()),
(tmp.path().join("scanned.jpg"), "scanned.jpg".to_string()),
(tmp.path().join("unhashed.jpg"), "unhashed.jpg".to_string()),
(tmp.path().join("clip.mp4"), "clip.mp4".to_string()),
];
let candidates = build_face_candidates(&ctx(), &library, &files, &exif_dao, &face_dao);
assert_eq!(
candidates.len(),
1,
"exactly fresh.jpg should be a candidate"
);
assert_eq!(candidates[0].rel_path, "fresh.jpg");
assert_eq!(candidates[0].content_hash, "hash-fresh");
}
}
+243
View File
@@ -0,0 +1,243 @@
//! Backfill `image_exif.phash_64` + `dhash_64` for image rows that
//! were ingested before perceptual hashing was wired into the watcher.
//!
//! The watcher computes perceptual hashes for new images as they're
//! ingested, so this binary is a one-shot for the historical backlog.
//! Idempotent — only rows with a non-null content_hash and a null
//! phash are processed, so re-runs are safe and pick up where they
//! left off (e.g. after a crash or interrupt).
//!
//! Image-only by design: `get_rows_missing_perceptual_hash` filters by
//! file extension at the DB layer so videos and other non-decodable
//! media are skipped without round-tripping `image_hasher`. Files that
//! can't be opened (missing on disk, permission errors) are quietly
//! left as null and counted as "missing"; on next run, if the file is
//! restored, the row will surface again.
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use clap::Parser;
use log::{error, warn};
use rayon::prelude::*;
use image_api::bin_progress;
use image_api::database::{ExifDao, SqliteExifDao, connect};
use image_api::libraries::{self, Library};
use image_api::perceptual_hash;
#[derive(Parser, Debug)]
#[command(name = "backfill_perceptual_hash")]
#[command(about = "Compute pHash + dHash for image_exif rows missing one")]
struct Args {
/// Max rows to hash per batch. The process loops until no rows remain.
#[arg(long, default_value_t = 256)]
batch_size: i64,
/// Rayon parallelism override. 0 uses the default thread pool size.
#[arg(long, default_value_t = 0)]
parallelism: usize,
/// Dry-run: log what would be hashed without writing to the DB.
#[arg(long)]
dry_run: bool,
}
fn main() -> anyhow::Result<()> {
env_logger::init();
dotenv::dotenv().ok();
let args = Args::parse();
if args.parallelism > 0 {
rayon::ThreadPoolBuilder::new()
.num_threads(args.parallelism)
.build_global()
.expect("Unable to configure rayon thread pool");
}
let base_path = dotenv::var("BASE_PATH").ok();
let mut seed_conn = connect();
if let Some(base) = base_path.as_deref() {
libraries::seed_or_patch_from_env(&mut seed_conn, base);
}
let libs = libraries::load_all(&mut seed_conn);
drop(seed_conn);
if libs.is_empty() {
anyhow::bail!("No libraries configured; cannot backfill perceptual hashes");
}
let libs_by_id: std::collections::HashMap<i32, Library> =
libs.into_iter().map(|lib| (lib.id, lib)).collect();
println!(
"Configured libraries: {}",
libs_by_id
.values()
.map(|l| format!("{} -> {}", l.name, l.root_path))
.collect::<Vec<_>>()
.join(", ")
);
let dao: Arc<Mutex<Box<dyn ExifDao>>> = Arc::new(Mutex::new(Box::new(SqliteExifDao::new())));
let ctx = opentelemetry::Context::new();
let mut total_hashed = 0u64;
let mut total_missing = 0u64;
let mut total_decode_failures = 0u64;
let mut total_errors = 0u64;
let start = Instant::now();
let pb = bin_progress::spinner("perceptual-hashing");
loop {
let rows = {
let mut guard = dao.lock().expect("Unable to lock ExifDao");
guard
.get_rows_missing_perceptual_hash(&ctx, args.batch_size)
.map_err(|e| anyhow::anyhow!("DB error: {:?}", e))?
};
if rows.is_empty() {
break;
}
let batch_size = rows.len();
pb.set_message(format!(
"batch of {} (hashed={} decode_fail={} missing={} errors={})",
batch_size, total_hashed, total_decode_failures, total_missing, total_errors
));
// Compute perceptual hashes in parallel — CPU-bound, decoder
// releases the GIL-equivalent. rayon's default thread pool
// matches the host's logical-core count which is the right
// ceiling for image_hasher's DCT pass.
let results: Vec<(i32, String, FilePerceptualResult)> = rows
.into_par_iter()
.map(|(library_id, rel_path)| {
let abs = libs_by_id
.get(&library_id)
.map(|lib| Path::new(&lib.root_path).join(&rel_path));
match abs {
Some(abs_path) if abs_path.exists() => {
match perceptual_hash::compute(&abs_path) {
Some(id) => (library_id, rel_path, FilePerceptualResult::Ok(id)),
None => (library_id, rel_path, FilePerceptualResult::DecodeFailed),
}
}
Some(_) => (library_id, rel_path, FilePerceptualResult::MissingOnDisk),
None => {
warn!("Row refers to unknown library_id {}", library_id);
(library_id, rel_path, FilePerceptualResult::MissingOnDisk)
}
}
})
.collect();
// Persist sequentially — SQLite writes serialize anyway.
if !args.dry_run {
let mut guard = dao.lock().expect("Unable to lock ExifDao");
for (library_id, rel_path, result) in &results {
match result {
FilePerceptualResult::Ok(id) => {
match guard.backfill_perceptual_hash(
&ctx,
*library_id,
rel_path,
Some(id.phash_64),
Some(id.dhash_64),
) {
Ok(_) => {
total_hashed += 1;
pb.inc(1);
}
Err(e) => {
pb.println(format!("persist error for {}: {:?}", rel_path, e));
total_errors += 1;
}
}
}
FilePerceptualResult::DecodeFailed => {
// Persist phash_64=0/dhash_64=0 as a "tried,
// unhashable" sentinel so this row leaves the
// `phash_64 IS NULL` candidate set and the
// backfill doesn't infinite-loop on a queue of
// unbreakable formats (HEIC, RAW, CMYK JPEGs,
// truncated bytes). The all-zero hash is
// explicitly excluded from clustering by
// is_informative_hash in duplicates.rs, so it
// won't pollute group output — it just becomes
// invisible to the duplicate finder.
log::debug!(
"perceptual decode failed for {} (lib {}); marking unhashable",
rel_path,
library_id
);
match guard.backfill_perceptual_hash(
&ctx,
*library_id,
rel_path,
Some(0),
Some(0),
) {
Ok(_) => {
total_decode_failures += 1;
}
Err(e) => {
pb.println(format!(
"persist error (decode-fail sentinel) for {}: {:?}",
rel_path, e
));
total_errors += 1;
}
}
}
FilePerceptualResult::MissingOnDisk => {
total_missing += 1;
}
}
}
} else {
for (_, rel_path, result) in &results {
match result {
FilePerceptualResult::Ok(id) => {
pb.println(format!(
"[dry-run] {} -> phash={:016x} dhash={:016x}",
rel_path, id.phash_64, id.dhash_64
));
total_hashed += 1;
pb.inc(1);
}
FilePerceptualResult::DecodeFailed => {
total_decode_failures += 1;
}
FilePerceptualResult::MissingOnDisk => {
total_missing += 1;
}
}
}
pb.println(format!(
"[dry-run] processed one batch of {}. Stopping — a real run would continue \
until no NULL phash_64 image rows remain.",
results.len()
));
break;
}
}
pb.finish_and_clear();
println!(
"Done. hashed={}, decode_failed={}, skipped (missing on disk)={}, errors={}, elapsed={:.1}s",
total_hashed,
total_decode_failures,
total_missing,
total_errors,
start.elapsed().as_secs_f64()
);
if total_errors > 0 {
error!("Backfill completed with {} persist errors", total_errors);
}
Ok(())
}
enum FilePerceptualResult {
Ok(perceptual_hash::PerceptualIdentity),
DecodeFailed,
MissingOnDisk,
}
+7 -19
View File
@@ -1,7 +1,7 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use chrono::Utc; use chrono::Utc;
use clap::Parser; use clap::Parser;
use image_api::ai::ollama::OllamaClient; use image_api::ai::LocalLlm;
use image_api::bin_progress; use image_api::bin_progress;
use image_api::database::calendar_dao::{InsertCalendarEvent, SqliteCalendarEventDao}; use image_api::database::calendar_dao::{InsertCalendarEvent, SqliteCalendarEventDao};
use image_api::parsers::ical_parser::parse_ics_file; use image_api::parsers::ical_parser::parse_ics_file;
@@ -44,22 +44,10 @@ async fn main() -> Result<()> {
let context = opentelemetry::Context::current(); let context = opentelemetry::Context::current();
let ollama = if args.generate_embeddings { // LocalLlm dispatches per LLM_BACKEND, so embeddings written here land
let primary_url = dotenv::var("OLLAMA_PRIMARY_URL") // in the same vector space the query side searches.
.or_else(|_| dotenv::var("OLLAMA_URL")) let llm = if args.generate_embeddings {
.unwrap_or_else(|_| "http://localhost:11434".to_string()); Some(LocalLlm::from_env())
let fallback_url = dotenv::var("OLLAMA_FALLBACK_URL").ok();
let primary_model = dotenv::var("OLLAMA_PRIMARY_MODEL")
.or_else(|_| dotenv::var("OLLAMA_MODEL"))
.unwrap_or_else(|_| "nomic-embed-text:v1.5".to_string());
let fallback_model = dotenv::var("OLLAMA_FALLBACK_MODEL").ok();
Some(OllamaClient::new(
primary_url,
fallback_url,
primary_model,
fallback_model,
))
} else { } else {
None None
}; };
@@ -90,7 +78,7 @@ async fn main() -> Result<()> {
} }
// Generate embedding if requested (blocking call) // Generate embedding if requested (blocking call)
let embedding = if let Some(ref ollama_client) = ollama { let embedding = if let Some(ref llm) = llm {
let text = format!( let text = format!(
"{} {} {}", "{} {} {}",
event.summary, event.summary,
@@ -100,7 +88,7 @@ async fn main() -> Result<()> {
match tokio::task::block_in_place(|| { match tokio::task::block_in_place(|| {
tokio::runtime::Handle::current() tokio::runtime::Handle::current()
.block_on(async { ollama_client.generate_embedding(&text).await }) .block_on(async { llm.embed_document(&text).await })
}) { }) {
Ok(emb) => Some(emb), Ok(emb) => Some(emb),
Err(e) => { Err(e) => {
+6 -14
View File
@@ -1,7 +1,7 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use chrono::Utc; use chrono::Utc;
use clap::Parser; use clap::Parser;
use image_api::ai::ollama::OllamaClient; use image_api::ai::LocalLlm;
use image_api::bin_progress; use image_api::bin_progress;
use image_api::database::search_dao::{InsertSearchRecord, SqliteSearchHistoryDao}; use image_api::database::search_dao::{InsertSearchRecord, SqliteSearchHistoryDao};
use image_api::parsers::search_html_parser::parse_search_html; use image_api::parsers::search_html_parser::parse_search_html;
@@ -38,16 +38,9 @@ async fn main() -> Result<()> {
info!("Found {} search records", searches.len()); info!("Found {} search records", searches.len());
let primary_url = dotenv::var("OLLAMA_PRIMARY_URL") // LocalLlm dispatches per LLM_BACKEND, so embeddings written here land
.or_else(|_| dotenv::var("OLLAMA_URL")) // in the same vector space the query side searches.
.unwrap_or_else(|_| "http://localhost:11434".to_string()); let llm = LocalLlm::from_env();
let fallback_url = dotenv::var("OLLAMA_FALLBACK_URL").ok();
let primary_model = dotenv::var("OLLAMA_PRIMARY_MODEL")
.or_else(|_| dotenv::var("OLLAMA_MODEL"))
.unwrap_or_else(|_| "nomic-embed-text:v1.5".to_string());
let fallback_model = dotenv::var("OLLAMA_FALLBACK_MODEL").ok();
let ollama = OllamaClient::new(primary_url, fallback_url, primary_model, fallback_model);
let context = opentelemetry::Context::current(); let context = opentelemetry::Context::current();
let mut inserted_count = 0usize; let mut inserted_count = 0usize;
@@ -67,12 +60,11 @@ async fn main() -> Result<()> {
let pb_for_warn = pb.clone(); let pb_for_warn = pb.clone();
let embeddings_result = tokio::task::spawn({ let embeddings_result = tokio::task::spawn({
let ollama_client = ollama.clone(); let llm = llm.clone();
async move { async move {
// Generate embeddings in parallel for the batch
let mut embeddings = Vec::new(); let mut embeddings = Vec::new();
for query in &queries { for query in &queries {
match ollama_client.generate_embedding(query).await { match llm.embed_document(query).await {
Ok(emb) => embeddings.push(Some(emb)), Ok(emb) => embeddings.push(Some(emb)),
Err(e) => { Err(e) => {
pb_for_warn.println(format!("embedding failed for '{}': {}", query, e)); pb_for_warn.println(format!("embedding failed for '{}': {}", query, e));
+12
View File
@@ -14,6 +14,7 @@ use image_api::database::{
SqliteInsightDao, SqliteKnowledgeDao, SqliteLocationHistoryDao, SqliteSearchHistoryDao, SqliteInsightDao, SqliteKnowledgeDao, SqliteLocationHistoryDao, SqliteSearchHistoryDao,
connect, connect,
}; };
use image_api::faces::{FaceDao, SqliteFaceDao};
use image_api::file_types::{IMAGE_EXTENSIONS, VIDEO_EXTENSIONS}; use image_api::file_types::{IMAGE_EXTENSIONS, VIDEO_EXTENSIONS};
use image_api::libraries::{self, Library}; use image_api::libraries::{self, Library};
use image_api::tags::{SqliteTagDao, TagDao}; use image_api::tags::{SqliteTagDao, TagDao};
@@ -182,6 +183,11 @@ async fn main() -> anyhow::Result<()> {
Arc::new(Mutex::new(Box::new(SqliteTagDao::default()))); Arc::new(Mutex::new(Box::new(SqliteTagDao::default())));
let knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>> = let knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>> =
Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new()))); Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new())));
let face_dao: Arc<Mutex<Box<dyn FaceDao>>> =
Arc::new(Mutex::new(Box::new(SqliteFaceDao::new())));
let persona_dao: Arc<Mutex<Box<dyn image_api::database::PersonaDao>>> = Arc::new(Mutex::new(
Box::new(image_api::database::SqlitePersonaDao::new()),
));
// Pass the full library set so `resolve_full_path` probes every root, // Pass the full library set so `resolve_full_path` probes every root,
// even when --library restricts the walk. A rel_path shared across // even when --library restricts the walk. A rel_path shared across
@@ -189,6 +195,7 @@ async fn main() -> anyhow::Result<()> {
let generator = InsightGenerator::new( let generator = InsightGenerator::new(
ollama, ollama,
None, None,
None,
sms_client, sms_client,
apollo_client, apollo_client,
insight_dao.clone(), insight_dao.clone(),
@@ -198,7 +205,9 @@ async fn main() -> anyhow::Result<()> {
location_dao, location_dao,
search_dao, search_dao,
tag_dao, tag_dao,
face_dao,
knowledge_dao, knowledge_dao,
persona_dao,
all_libs.clone(), all_libs.clone(),
); );
@@ -327,10 +336,13 @@ async fn main() -> anyhow::Result<()> {
args.top_p, args.top_p,
args.top_k, args.top_k,
args.min_p, args.min_p,
None, // enable_thinking: leave model/template default
args.max_iterations, args.max_iterations,
None, None,
Vec::new(), Vec::new(),
Vec::new(), Vec::new(),
1, // operator user_id — populate_knowledge is single-user offline tool
"default".to_string(),
) )
.await .await
{ {
+273
View File
@@ -0,0 +1,273 @@
//! Probe binary for CLIP semantic search.
//!
//! No DB writes. Walks a library's `image_exif` rows, encodes a sample
//! via Apollo's `/encode_image`, encodes the user's --query via
//! `/encode_text`, and prints the top-K most similar photos by cosine
//! similarity so the operator can eyeball quality before committing to
//! the persistence phase (column populated by backlog drain, search
//! endpoint, UI).
//!
//! Usage:
//! cargo run --release --bin probe_clip_search -- \
//! --library 1 --limit 200 --query "a beach at sunset" --top 10
//!
//! Env: standard ImageApi `.env`. Requires either
//! `APOLLO_CLIP_API_BASE_URL` or `APOLLO_API_BASE_URL` to be set.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use clap::Parser;
use log::{info, warn};
use image_api::ai::clip_client::{ClipClient, ClipError, EncodeImageMeta};
use image_api::database::{ExifDao, SqliteExifDao, connect};
use image_api::exif;
use image_api::file_types;
use image_api::libraries::{self, Library};
#[derive(Parser, Debug)]
#[command(name = "probe_clip_search")]
#[command(about = "Top-K CLIP semantic search over a sample of image_exif rows")]
struct Args {
/// Library id to sample from.
#[arg(long)]
library: i32,
/// Max files to encode. CPU inference is slow (~1-3 s per photo at
/// ViT-L/14); start small and grow once GPU is sorted.
#[arg(long, default_value_t = 50)]
limit: usize,
/// Natural-language query. Empty triggers an error from Apollo.
#[arg(long)]
query: String,
/// How many top results to print.
#[arg(long, default_value_t = 10)]
top: usize,
/// Offset into the library's rel_path listing.
#[arg(long, default_value_t = 0)]
offset: i64,
/// How many DB rows to scan before giving up on hitting the limit.
#[arg(long, default_value_t = 5000)]
max_scan: i64,
}
/// Same as `face_watch::read_image_bytes_for_detect` (which is pub(crate)).
/// Inlined for the throwaway probe.
fn read_image_bytes(path: &Path) -> std::io::Result<Vec<u8>> {
if file_types::needs_ffmpeg_thumbnail(path)
&& let Some(preview) = exif::extract_embedded_jpeg_preview(path)
{
return Ok(preview);
}
std::fs::read(path)
}
/// Decode a base64'd LE float32 vector to a `Vec<f32>`.
fn decode_f32_vec(b64: &str) -> anyhow::Result<Vec<f32>> {
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD.decode(b64.as_bytes())?;
if bytes.len() % 4 != 0 {
anyhow::bail!("embedding byte length {} not divisible by 4", bytes.len());
}
let mut out = Vec::with_capacity(bytes.len() / 4);
for chunk in bytes.chunks_exact(4) {
out.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
}
Ok(out)
}
/// Plain dot product. Apollo L2-normalizes both sides, so this is cosine sim.
fn dot(a: &[f32], b: &[f32]) -> f32 {
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
env_logger::init();
dotenv::dotenv().ok();
let args = Args::parse();
if args.query.trim().is_empty() {
anyhow::bail!("--query must not be empty");
}
let client = ClipClient::from_env();
if !client.is_enabled() {
anyhow::bail!(
"ClipClient disabled: set APOLLO_CLIP_API_BASE_URL or APOLLO_API_BASE_URL in .env"
);
}
match client.health().await {
Ok(h) => info!(
"clip engine: loaded={} device={} model={} dim={}",
h.loaded, h.device, h.model_version, h.embedding_dim
),
Err(e) => warn!("health probe failed (continuing): {e}"),
}
let mut seed_conn = connect();
if let Some(base) = dotenv::var("BASE_PATH").ok().as_deref() {
libraries::seed_or_patch_from_env(&mut seed_conn, base);
}
let libs = libraries::load_all(&mut seed_conn);
drop(seed_conn);
let lib: Library = libs
.into_iter()
.find(|l| l.id == args.library)
.ok_or_else(|| anyhow::anyhow!("library id {} not found", args.library))?;
info!(
"probing library #{} ({}) at {}",
lib.id, lib.name, lib.root_path
);
let dao: Arc<Mutex<Box<dyn ExifDao>>> = Arc::new(Mutex::new(Box::new(SqliteExifDao::new())));
let ctx = opentelemetry::Context::new();
// Encode the query up-front so the long image-encode loop doesn't
// race a slow query encode. Fails fast on a misspelled query.
let query_resp = client
.encode_text(&args.query)
.await
.map_err(|e| anyhow::anyhow!("encode_text: {e}"))?;
let query_vec = decode_f32_vec(&query_resp.embedding)?;
info!(
"query encoded ({}d, {}ms): {:?}",
query_resp.embedding_dim, query_resp.duration_ms, args.query
);
// Page through (id, rel_path), filter to images on disk, encode up
// to `limit`. Each encoded photo gets scored against the query and
// kept in a top-K heap.
const PAGE: i64 = 500;
let mut offset = args.offset;
let mut scanned: i64 = 0;
let mut encoded = 0usize;
let mut perm_fail = 0usize;
let mut transient_fail = 0usize;
let root = PathBuf::from(&lib.root_path);
let started = Instant::now();
// (similarity, rel_path) — we keep all scored results and sort at
// the end. With limit≤few-hundred this is trivial.
let mut scores: Vec<(f32, String)> = Vec::with_capacity(args.limit);
'outer: loop {
if scanned >= args.max_scan {
warn!(
"scan cap ({}) reached before hitting limit ({}); bump --max-scan to scan deeper",
args.max_scan, args.limit
);
break;
}
let rows = {
let mut guard = dao.lock().expect("dao lock");
guard
.list_rel_paths_for_library_page(&ctx, lib.id, PAGE, offset)
.map_err(|e| anyhow::anyhow!("list rel_paths: {:?}", e))?
};
if rows.is_empty() {
info!("no more rows after offset {}", offset);
break;
}
offset += rows.len() as i64;
scanned += rows.len() as i64;
for (_id, rel_path) in rows {
if encoded >= args.limit {
break 'outer;
}
let abs = root.join(&rel_path);
if !file_types::is_image_file(&abs) || !abs.exists() {
continue;
}
let bytes = match read_image_bytes(&abs) {
Ok(b) => b,
Err(e) => {
warn!("read {rel_path}: {e}");
continue;
}
};
let meta = EncodeImageMeta {
content_hash: String::new(),
library_id: lib.id,
rel_path: rel_path.clone(),
};
let call_start = Instant::now();
match client.encode_image(bytes, meta).await {
Ok(resp) => {
encoded += 1;
let vec = match decode_f32_vec(&resp.embedding) {
Ok(v) => v,
Err(e) => {
warn!("decode {rel_path}: {e}");
continue;
}
};
if vec.len() != query_vec.len() {
warn!(
"dim mismatch for {rel_path}: image={} query={}",
vec.len(),
query_vec.len()
);
continue;
}
let sim = dot(&vec, &query_vec);
scores.push((sim, rel_path.clone()));
if encoded.is_multiple_of(10) {
info!(
"progress: {} encoded, {:.1}s elapsed",
encoded,
started.elapsed().as_secs_f32()
);
}
let _ = call_start;
}
Err(ClipError::Permanent(e)) => {
perm_fail += 1;
warn!("permanent encode failure for {rel_path}: {e}");
}
Err(ClipError::Transient(e)) => {
transient_fail += 1;
warn!("transient encode failure for {rel_path}: {e}");
}
Err(ClipError::Disabled) => {
anyhow::bail!("clip client became disabled mid-run; impossible");
}
}
}
}
scores.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let elapsed = started.elapsed();
println!();
println!(
"── top {} for query: {:?} ──",
args.top.min(scores.len()),
args.query
);
for (i, (sim, path)) in scores.iter().take(args.top).enumerate() {
println!("[{:>2}] sim={:.3} {}", i + 1, sim, path);
}
println!();
println!("── summary ─────────────────────────────────────");
println!("query : {:?}", args.query);
println!("scanned rows : {scanned}");
println!("encoded photos : {encoded}");
println!("permanent failures : {perm_fail}");
println!("transient failures : {transient_fail}");
println!("elapsed : {:.1}s", elapsed.as_secs_f32());
if encoded > 0 {
println!(
"throughput : {:.2} photos/s ({:.0}ms/photo avg)",
encoded as f32 / elapsed.as_secs_f32().max(0.001),
elapsed.as_millis() as f32 / encoded as f32
);
}
Ok(())
}
+465
View File
@@ -0,0 +1,465 @@
//! Re-embed stored corpora through `LocalLlm`, i.e. the same
//! `LLM_BACKEND` dispatch the query side uses. The original import /
//! backfill tools always embedded via Ollama, so a deploy running
//! `LLM_BACKEND=llamacpp` queries vector spaces the corpora may not live
//! in. Three tables share the problem and are all covered here:
//!
//! - `daily_conversation_summaries` — re-embeds
//! `strip_summary_boilerplate(summary)` (what the original job fed the
//! embedder); also rewrites `model_version`.
//! - `calendar_events` — re-embeds "summary description location" exactly
//! as `import_calendar` does; rows without an embedding are skipped (the
//! import only embeds under `--generate-embeddings`).
//! - `search_history` — re-embeds the raw query text.
//! - `entities` (knowledge graph) — re-embeds "name description" exactly as
//! `tool_store_entity` does; embedding-less rows are skipped (embedding
//! is best-effort at store time).
//!
//! Source text is untouched — only vectors are rewritten. The old↔new
//! cosine report doubles as a diagnostic: ~1.0 means both backends already
//! shared a space (re-embedding was a no-op); low values confirm the
//! mismatch this tool exists to fix.
use anyhow::{Context, Result};
use clap::Parser;
use diesel::prelude::*;
use diesel::sql_query;
use diesel::sqlite::SqliteConnection;
use image_api::ai::{LocalLlm, strip_summary_boilerplate};
use image_api::bin_progress;
use std::env;
#[derive(Parser, Debug)]
#[command(author, version, about = "Re-embed stored corpora via the configured LLM_BACKEND", long_about = None)]
struct Args {
/// Comma-separated tables to process: summaries, calendar, search, entities
#[arg(long, default_value = "summaries,calendar,search,entities")]
tables: String,
/// Only process the first N rows per table (smoke test)
#[arg(long)]
limit: Option<usize>,
/// Compute embeddings and report old↔new similarity without writing
#[arg(long, default_value_t = false)]
dry_run: bool,
}
#[derive(QueryableByName)]
struct SummaryRow {
#[diesel(sql_type = diesel::sql_types::Integer)]
id: i32,
#[diesel(sql_type = diesel::sql_types::Text)]
summary: String,
#[diesel(sql_type = diesel::sql_types::Binary)]
embedding: Vec<u8>,
#[diesel(sql_type = diesel::sql_types::Text)]
model_version: String,
}
#[derive(QueryableByName)]
struct CalendarRow {
#[diesel(sql_type = diesel::sql_types::Integer)]
id: i32,
#[diesel(sql_type = diesel::sql_types::Text)]
summary: String,
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
description: Option<String>,
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
location: Option<String>,
#[diesel(sql_type = diesel::sql_types::Binary)]
embedding: Vec<u8>,
}
#[derive(QueryableByName)]
struct SearchRow {
#[diesel(sql_type = diesel::sql_types::BigInt)]
id: i64,
#[diesel(sql_type = diesel::sql_types::Text)]
query: String,
#[diesel(sql_type = diesel::sql_types::Binary)]
embedding: Vec<u8>,
}
#[derive(QueryableByName)]
struct EntityRow {
#[diesel(sql_type = diesel::sql_types::Integer)]
id: i32,
#[diesel(sql_type = diesel::sql_types::Text)]
name: String,
#[diesel(sql_type = diesel::sql_types::Text)]
description: String,
#[diesel(sql_type = diesel::sql_types::Binary)]
embedding: Vec<u8>,
}
/// One unit of re-embed work, normalized across tables.
struct WorkItem {
/// Row key, as i64 so both i32 ids and rowids fit.
id: i64,
/// Text fed to the embedder — must match what the original writer used.
text: String,
/// Existing vector bytes, for the old↔new similarity report.
old_embedding: Vec<u8>,
}
fn deserialize_vector(bytes: &[u8]) -> Option<Vec<f32>> {
if !bytes.len().is_multiple_of(4) {
return None;
}
Some(
bytes
.chunks_exact(4)
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect(),
)
}
fn serialize_vector(vec: &[f32]) -> Vec<u8> {
vec.iter().flat_map(|f| f.to_le_bytes()).collect()
}
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() {
return 0.0;
}
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
let mag_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let mag_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if mag_a == 0.0 || mag_b == 0.0 {
return 0.0;
}
dot / (mag_a * mag_b)
}
/// Embed `text`, halving it on "input too large" errors until it fits the
/// server's physical batch (`--ubatch-size`). Mirrors the silent truncation
/// Ollama applied when these corpora were first embedded — llama-server
/// returns a 500 instead — except here it's surfaced via the returned flag.
/// Returns `(embedding, truncated)`.
async fn embed_with_truncation(llm: &LocalLlm, text: &str) -> Result<(Vec<f32>, bool)> {
let mut text = text.to_string();
let mut truncated = false;
loop {
match llm.embed_document(&text).await {
Ok(emb) => return Ok((emb, truncated)),
Err(e)
if e.to_string().contains("too large to process") && text.chars().count() > 64 =>
{
let keep = text.chars().count() / 2;
text = text.chars().take(keep).collect();
truncated = true;
}
Err(e) => return Err(e),
}
}
}
/// Re-embed `items`, writing each new vector via `update`. Returns the
/// old↔new cosines for the similarity report.
async fn reembed_table(
conn: &mut SqliteConnection,
llm: &LocalLlm,
label: &str,
items: Vec<WorkItem>,
dry_run: bool,
update: impl Fn(&mut SqliteConnection, i64, Vec<u8>) -> Result<()>,
) -> Result<Vec<f32>> {
println!("\n[{}] re-embedding {} rows...", label, items.len());
let pb = bin_progress::determinate(items.len() as u64, format!("re-embedding {}", label));
let mut sims: Vec<f32> = Vec::with_capacity(items.len());
let mut updated = 0usize;
let mut failed = 0usize;
let mut truncated_count = 0usize;
for item in &items {
let new_emb = match embed_with_truncation(llm, &item.text).await {
Ok((e, truncated)) => {
if truncated {
truncated_count += 1;
pb.println(format!(
"⚠ {} id={}: input exceeded the embed server's batch size, \
truncated before embedding",
label, item.id
));
}
e
}
Err(e) => {
pb.inc(1);
failed += 1;
eprintln!("{} id={}: {}", label, item.id, e);
continue;
}
};
// The whole pipeline (DAO checks, stored corpora) assumes
// EMBEDDING_DIM dims. A mismatch means the active embed slot is not
// serving the configured model — stop rather than corrupt the table.
anyhow::ensure!(
new_emb.len() == image_api::ai::embedding_dim(),
"backend returned {}-dim embedding (expected {}) — '{}' does not \
match the configured EMBEDDING_DIM",
new_emb.len(),
image_api::ai::embedding_dim(),
llm.embedding_model_version()
);
if let Some(old_emb) = deserialize_vector(&item.old_embedding) {
sims.push(cosine_similarity(&old_emb, &new_emb));
}
if !dry_run {
update(conn, item.id, serialize_vector(&new_emb))
.with_context(|| format!("updating {} id={}", label, item.id))?;
}
updated += 1;
pb.inc(1);
}
pb.finish_and_clear();
println!(
"[{}] {} re-embedded ({} truncated), {} failed",
label, updated, truncated_count, failed
);
Ok(sims)
}
fn report_similarity(label: &str, mut sims: Vec<f32>) {
if sims.is_empty() {
println!("[{}] no old↔new pairs to compare", label);
return;
}
sims.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mean: f32 = sims.iter().sum::<f32>() / sims.len() as f32;
let median = sims[sims.len() / 2];
println!(
"[{}] old↔new cosine over identical text: min={:.3} median={:.3} mean={:.3} max={:.3}",
label,
sims.first().unwrap(),
median,
mean,
sims.last().unwrap()
);
if median > 0.98 {
println!(
"[{}] → old and new backends agree (~same vector space); poor search \
results are coming from something else (prefixes, thresholds, corpus).",
label
);
} else if median > 0.9 {
println!(
"[{}] → same model family but measurably different vectors \
(quantization / runtime drift); re-embedding was worthwhile.",
label
);
} else {
println!(
"[{}] → vector-space mismatch confirmed — queries were searching a \
different space than the corpus. This re-embed should fix it.",
label
);
}
}
#[tokio::main]
async fn main() -> Result<()> {
dotenv::dotenv().ok();
env_logger::init();
let args = Args::parse();
let tables: Vec<&str> = args.tables.split(',').map(|t| t.trim()).collect();
for t in &tables {
anyhow::ensure!(
matches!(*t, "summaries" | "calendar" | "search" | "entities"),
"unknown table '{}' — expected summaries, calendar, search, entities",
t
);
}
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "auth.db".to_string());
println!("Database: {}", database_url);
let mut conn = SqliteConnection::establish(&database_url)
.with_context(|| format!("connecting to {}", database_url))?;
let llm = LocalLlm::from_env();
let model_version = llm.embedding_model_version();
println!("Embedding via '{}'", model_version);
if args.dry_run {
println!("DRY RUN — no rows will be written");
}
if tables.contains(&"summaries") {
let mut rows: Vec<SummaryRow> = sql_query(
"SELECT id, summary, embedding, model_version
FROM daily_conversation_summaries ORDER BY date",
)
.load(&mut conn)
.context("loading daily summaries")?;
if let Some(limit) = args.limit {
rows.truncate(limit);
}
if let Some(first) = rows.first() {
println!(
"\n[summaries] previous model_version '{}' → '{}'",
first.model_version, model_version
);
}
let items = rows
.into_iter()
.map(|r| WorkItem {
id: r.id as i64,
text: strip_summary_boilerplate(&r.summary),
old_embedding: r.embedding,
})
.collect();
let mv = model_version.clone();
let sims = reembed_table(
&mut conn,
&llm,
"summaries",
items,
args.dry_run,
move |conn, id, emb| {
sql_query(
"UPDATE daily_conversation_summaries
SET embedding = ?1, model_version = ?2 WHERE id = ?3",
)
.bind::<diesel::sql_types::Binary, _>(emb)
.bind::<diesel::sql_types::Text, _>(&mv)
.bind::<diesel::sql_types::Integer, _>(id as i32)
.execute(conn)?;
Ok(())
},
)
.await?;
report_similarity("summaries", sims);
}
if tables.contains(&"calendar") {
let mut rows: Vec<CalendarRow> = sql_query(
"SELECT id, summary, description, location, embedding
FROM calendar_events WHERE embedding IS NOT NULL ORDER BY id",
)
.load(&mut conn)
.context("loading calendar events")?;
if let Some(limit) = args.limit {
rows.truncate(limit);
}
let items = rows
.into_iter()
.map(|r| WorkItem {
id: r.id as i64,
// Same text construction as import_calendar.
text: format!(
"{} {} {}",
r.summary,
r.description.as_deref().unwrap_or(""),
r.location.as_deref().unwrap_or("")
),
old_embedding: r.embedding,
})
.collect();
let sims = reembed_table(
&mut conn,
&llm,
"calendar",
items,
args.dry_run,
|conn, id, emb| {
sql_query("UPDATE calendar_events SET embedding = ?1 WHERE id = ?2")
.bind::<diesel::sql_types::Binary, _>(emb)
.bind::<diesel::sql_types::Integer, _>(id as i32)
.execute(conn)?;
Ok(())
},
)
.await?;
report_similarity("calendar", sims);
}
if tables.contains(&"search") {
let mut rows: Vec<SearchRow> = sql_query(
"SELECT rowid AS id, query, embedding
FROM search_history ORDER BY rowid",
)
.load(&mut conn)
.context("loading search history")?;
if let Some(limit) = args.limit {
rows.truncate(limit);
}
let items = rows
.into_iter()
.map(|r| WorkItem {
id: r.id,
text: r.query,
old_embedding: r.embedding,
})
.collect();
let sims = reembed_table(
&mut conn,
&llm,
"search",
items,
args.dry_run,
|conn, id, emb| {
sql_query("UPDATE search_history SET embedding = ?1 WHERE rowid = ?2")
.bind::<diesel::sql_types::Binary, _>(emb)
.bind::<diesel::sql_types::BigInt, _>(id)
.execute(conn)?;
Ok(())
},
)
.await?;
report_similarity("search", sims);
}
if tables.contains(&"entities") {
let mut rows: Vec<EntityRow> = sql_query(
"SELECT id, name, description, embedding
FROM entities WHERE embedding IS NOT NULL ORDER BY id",
)
.load(&mut conn)
.context("loading knowledge entities")?;
if let Some(limit) = args.limit {
rows.truncate(limit);
}
let items = rows
.into_iter()
.map(|r| WorkItem {
id: r.id as i64,
// Same text construction as tool_store_entity.
text: format!("{} {}", r.name, r.description),
old_embedding: r.embedding,
})
.collect();
let sims = reembed_table(
&mut conn,
&llm,
"entities",
items,
args.dry_run,
|conn, id, emb| {
sql_query("UPDATE entities SET embedding = ?1 WHERE id = ?2")
.bind::<diesel::sql_types::Binary, _>(emb)
.bind::<diesel::sql_types::Integer, _>(id as i32)
.execute(conn)?;
Ok(())
},
)
.await?;
report_similarity("entities", sims);
}
println!(
"\n{}",
if args.dry_run {
"Dry run complete"
} else {
"Done"
}
);
Ok(())
}
+382
View File
@@ -0,0 +1,382 @@
//! `/photos/search?q=<text>` — CLIP semantic photo search.
//!
//! The route lives outside `files.rs` to keep that 1500+ line module
//! focused on EXIF / tag listing. The flow is:
//!
//! 1. Parse query params (`q`, `limit`, `threshold`, optional `library`).
//! 2. Call Apollo's `/api/internal/clip/encode_text` to get the query
//! vector (L2-normalized 768-d f32 for ViT-L/14).
//! 3. Load every `(content_hash, clip_embedding)` for the scope from
//! `image_exif` via `ExifDao::list_clip_index`. ~2843 MB for a 14k
//! library at ViT-L/14; loaded fresh per request — fast enough for
//! v1, optimize via an AppState cache later if needed.
//! 4. Dot product (= cosine since both sides are L2-normalized), filter
//! above `threshold`, top-K by score.
//! 5. Resolve each surviving hash back to a `(library_id, rel_path)` so
//! the frontend can render the photo / hand off to the carousel.
//!
//! Response shape is intentionally minimal — paths + score — so the
//! frontend can reuse existing PhotoGrid rendering by joining against
//! `/api/photos/match` (or calling `/image/metadata` lazily). Don't
//! bake camera/EXIF metadata into this route; it would force a fan-out
//! per result and balloon the response.
use crate::AppState;
use crate::ai::clip_client::ClipError;
use crate::database::ExifDao;
use actix_web::{HttpResponse, Result as ActixResult, web};
use base64::Engine;
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
#[derive(Debug, Deserialize)]
pub struct SearchQuery {
/// Natural-language query. Required; empty triggers 400.
pub q: String,
/// Max results to return in this page. Capped to 200 server-side.
/// Defaults to 20. Pair with `offset` for pagination.
#[serde(default = "default_limit")]
pub limit: usize,
/// Zero-based offset into the sorted-and-filtered result set. The
/// scoring loop still runs over the full embedding matrix on every
/// page (cheap at personal-library scale — sub-100ms — and avoids
/// stateful pagination cursors). Defaults to 0.
#[serde(default)]
pub offset: usize,
/// Cosine-similarity floor below which results are dropped.
/// 0.20 is the rough "this is plausibly relevant" line for OpenAI
/// CLIP; tunable per call when sweeping. Defaults to 0.20.
#[serde(default = "default_threshold")]
pub threshold: f32,
/// Optional single-library scope. Legacy param — new clients pass
/// `library_ids` instead so multi-select scopes (Apollo's HUD library
/// chips, FileViewer-React's library picker) actually filter. Kept
/// for back-compat; `library_ids` wins when both are supplied.
pub library: Option<i32>,
/// Optional multi-library scope, comma-separated id list
/// (`?library_ids=1,3`). Empty / omitted = every enabled library
/// (the historical default). Apollo and FileViewer-React both send
/// this when 2+ libraries are selected; the single-library case
/// works through either param interchangeably.
pub library_ids: Option<String>,
/// Optional model-version filter. Defaults to the live engine's
/// version (queried lazily). Forces a strict join so mid-flight
/// model swaps can't mix geometries in a single response.
#[serde(default)]
pub model_version: Option<String>,
}
fn default_limit() -> usize {
20
}
fn default_threshold() -> f32 {
0.20
}
#[derive(Debug, Serialize)]
pub struct SearchHit {
pub library_id: i32,
pub rel_path: String,
pub content_hash: String,
/// Cosine similarity in [-1, 1]. In practice OpenAI CLIP returns
/// 0.100.40 for the typical photo library.
pub score: f32,
}
#[derive(Debug, Serialize)]
pub struct SearchResponse {
pub query: String,
pub model_version: String,
pub threshold: f32,
/// Total embeddings scored (= every photo in scope with a stored
/// embedding). Same value across pages of the same query.
pub considered: usize,
/// Count of results above threshold, before pagination. Lets the
/// client decide whether a "Load more" button is meaningful and
/// stop fetching when ``offset + results.len() >= total_matching``.
pub total_matching: usize,
pub offset: usize,
pub results: Vec<SearchHit>,
}
#[derive(Debug, Serialize)]
struct SearchError {
error: String,
}
/// Decode a stored `clip_embedding` BLOB back into a `Vec<f32>`. Returns
/// `None` on malformed bytes — those rows get skipped rather than
/// failing the whole query.
fn decode_embedding(bytes: &[u8]) -> Option<Vec<f32>> {
if bytes.is_empty() || !bytes.len().is_multiple_of(4) {
return None;
}
let mut out = Vec::with_capacity(bytes.len() / 4);
for chunk in bytes.chunks_exact(4) {
out.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
}
Some(out)
}
#[inline]
fn dot(a: &[f32], b: &[f32]) -> f32 {
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
}
/// Failure modes of [`score_photos`]. Carries enough to let each caller pick
/// an appropriate HTTP status (the CLIP service being down is a 502, a
/// disabled feature is a 503, a rejected query is a 400, a DB failure 500).
pub enum ScoreError {
/// CLIP search isn't configured at all (no Apollo endpoint).
Disabled,
/// The query was rejected by the encoder (client error).
Rejected(String),
/// The CLIP service is transiently unavailable (upstream error).
Unavailable(String),
/// The encoder returned an embedding we couldn't decode.
MalformedEmbedding,
/// A database / index load failure.
Internal(String),
}
/// Result of scoring the whole library against a query embedding: the
/// resolved model version, how many embeddings were considered, and every
/// `(score, content_hash)` above threshold, sorted by descending score.
/// Pagination and path resolution are the caller's job (see [`resolve_hits`])
/// so this core can be reused for both the plain search endpoint and the
/// unified endpoint (which filters by hash before paginating).
pub struct ScoredPhotos {
pub model_version: String,
pub considered: usize,
/// `(cosine_score, content_hash)` pairs, descending by score.
pub hits: Vec<(f32, String)>,
}
/// Encode `q_text` via CLIP and score it against every stored embedding in
/// the given library scope. Returns all matches above `threshold`, sorted by
/// descending similarity. Pure of HTTP concerns so it's shared by
/// `search_photos` and the unified search endpoint.
pub async fn score_photos(
state: &AppState,
exif_dao: &Mutex<Box<dyn ExifDao>>,
q_text: &str,
library_ids: &[i32],
threshold: f32,
model_version: Option<&str>,
) -> Result<ScoredPhotos, ScoreError> {
if !state.clip_client.is_enabled() {
return Err(ScoreError::Disabled);
}
// 1. Encode the query text. Fast — Apollo's text encoder is ~50ms on CPU.
let query_resp = match state.clip_client.encode_text(q_text).await {
Ok(r) => r,
Err(ClipError::Permanent(e)) => return Err(ScoreError::Rejected(e.to_string())),
Err(ClipError::Transient(e)) => return Err(ScoreError::Unavailable(e.to_string())),
Err(ClipError::Disabled) => return Err(ScoreError::Disabled),
};
// decode_embedding works on raw bytes; the wire format is b64.
let query_bytes = base64::engine::general_purpose::STANDARD
.decode(query_resp.embedding.as_bytes())
.unwrap_or_default();
let query_vec = decode_embedding(&query_bytes).ok_or(ScoreError::MalformedEmbedding)?;
// 2. Pull the (hash, embedding) matrix under the dao lock, release
// before scoring. The caller-supplied `model_version` (or the live
// engine's) forces a strict join so a mid-flight model swap can't mix
// geometries.
let ctx = opentelemetry::Context::current();
let rows: Vec<(String, Vec<u8>)> = {
let mut dao = exif_dao.lock().expect("exif dao");
dao.list_clip_index(
&ctx,
library_ids,
model_version.or(Some(&query_resp.model_version)),
)
.map_err(|e| {
log::warn!("clip_search: list_clip_index failed: {:?}", e);
ScoreError::Internal("failed to load search index".into())
})?
};
let considered = rows.len();
// 3. Score. Keep all matches and sort at the end (~microseconds at 14k).
let mut hits: Vec<(f32, String)> = Vec::with_capacity(considered);
for (hash, blob) in rows {
let Some(emb) = decode_embedding(&blob) else {
continue;
};
if emb.len() != query_vec.len() {
continue;
}
let sim = dot(&emb, &query_vec);
if sim < threshold {
continue;
}
hits.push((sim, hash));
}
hits.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
Ok(ScoredPhotos {
model_version: query_resp.model_version,
considered,
hits,
})
}
/// Resolve a page of `(score, content_hash)` pairs back to [`SearchHit`]s
/// (each carrying `library_id` + `rel_path`). Hashes that no longer resolve
/// to a row are skipped. Shared by both endpoints.
pub fn resolve_hits(
exif_dao: &Mutex<Box<dyn ExifDao>>,
scored: &[(f32, String)],
) -> Vec<SearchHit> {
if scored.is_empty() {
return Vec::new();
}
let ctx = opentelemetry::Context::current();
let hashes: Vec<String> = scored.iter().map(|(_, h)| h.clone()).collect();
let mut dao = exif_dao.lock().expect("exif dao");
let path_map = dao
.get_rel_paths_for_hashes(&ctx, &hashes)
.unwrap_or_else(|e| {
log::warn!("clip_search: get_rel_paths_for_hashes failed: {:?}", e);
std::collections::HashMap::new()
});
let mut results = Vec::with_capacity(scored.len());
for (score, hash) in scored {
let row = match dao.find_by_content_hash(&ctx, hash) {
Ok(Some(r)) => r,
Ok(None) => continue,
Err(e) => {
log::warn!("clip_search: find_by_content_hash failed for {hash}: {e:?}");
continue;
}
};
// Prefer get_rel_paths_for_hashes's first entry (shares image_exif's
// natural order), falling back to the ImageExif row.
let rel_path = path_map
.get(hash)
.and_then(|paths| paths.first().cloned())
.unwrap_or(row.file_path);
results.push(SearchHit {
library_id: row.library_id,
rel_path,
content_hash: hash.clone(),
score: *score,
});
}
results
}
/// Parse the `library_ids` (multi) / `library` (single) scope params into a
/// deduped id list. Empty = "every enabled library". Shared so the unified
/// endpoint scopes CLIP identically.
pub fn parse_library_scope(
library_ids: Option<&str>,
library: Option<i32>,
) -> Result<Vec<i32>, String> {
if let Some(raw) = library_ids {
let mut out: Vec<i32> = Vec::new();
for piece in raw.split(',') {
let trimmed = piece.trim();
if trimmed.is_empty() {
continue;
}
match trimmed.parse::<i32>() {
Ok(id) => {
if !out.contains(&id) {
out.push(id);
}
}
Err(_) => return Err(format!("invalid library_ids entry: {trimmed:?}")),
}
}
Ok(out)
} else if let Some(id) = library {
Ok(vec![id])
} else {
Ok(Vec::new())
}
}
pub async fn search_photos(
state: web::Data<AppState>,
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
query: web::Query<SearchQuery>,
) -> ActixResult<HttpResponse> {
let q_text = query.q.trim().to_string();
if q_text.is_empty() {
return Ok(HttpResponse::BadRequest().json(SearchError {
error: "query parameter `q` is required".into(),
}));
}
let limit = query.limit.clamp(1, 200);
let offset = query.offset;
let threshold = query.threshold.clamp(-1.0, 1.0);
let library_ids = match parse_library_scope(query.library_ids.as_deref(), query.library) {
Ok(ids) => ids,
Err(msg) => return Ok(HttpResponse::BadRequest().json(SearchError { error: msg })),
};
let scored = match score_photos(
&state,
&exif_dao,
&q_text,
&library_ids,
threshold,
query.model_version.as_deref(),
)
.await
{
Ok(s) => s,
Err(e) => return Ok(score_error_response(e)),
};
let total_matching = scored.hits.len();
// Pagination — slice the sorted list at `[offset, offset+limit)`. Offsets
// past the end produce empty pages so "load more" stops naturally.
let page: Vec<(f32, String)> = if offset >= total_matching {
Vec::new()
} else {
let end = (offset + limit).min(total_matching);
scored.hits[offset..end].to_vec()
};
let results = resolve_hits(&exif_dao, &page);
Ok(HttpResponse::Ok().json(SearchResponse {
query: q_text,
model_version: scored.model_version,
threshold,
considered: scored.considered,
total_matching,
offset,
results,
}))
}
/// Map a [`ScoreError`] to the HTTP response `search_photos` historically
/// returned for each failure mode. Reused by the unified endpoint.
pub fn score_error_response(e: ScoreError) -> HttpResponse {
match e {
ScoreError::Disabled => HttpResponse::ServiceUnavailable().json(SearchError {
error: "CLIP search is disabled (no Apollo CLIP endpoint configured)".into(),
}),
ScoreError::Rejected(msg) => HttpResponse::BadRequest().json(SearchError {
error: format!("query rejected: {msg}"),
}),
ScoreError::Unavailable(msg) => HttpResponse::BadGateway().json(SearchError {
error: format!("CLIP service unavailable: {msg}"),
}),
ScoreError::MalformedEmbedding => HttpResponse::BadGateway().json(SearchError {
error: "CLIP service returned a malformed query embedding".into(),
}),
ScoreError::Internal(msg) => {
HttpResponse::InternalServerError().json(SearchError { error: msg })
}
}
}
+246
View File
@@ -0,0 +1,246 @@
//! CLIP-encoding pass for the file watcher.
//!
//! `process_clip_backlog` in `backfill.rs` calls [`run_clip_encoding_pass`]
//! with the page of candidates returned by
//! `ExifDao::list_clip_unencoded_candidates`. We walk those, fan out K
//! parallel encode calls to Apollo, and persist the resulting embeddings
//! into `image_exif.clip_embedding` / `clip_model_version`.
//!
//! Unlike the face pipeline, CLIP has no marker rows — a permanent
//! failure (un-decodable bytes) leaves the row's `clip_embedding` NULL
//! and the drain will retry on the next tick. For personal-library
//! scale this is fine; the per-tick cap bounds the wasted work, and
//! `file_types::is_image_file` filters out videos / non-media client-
//! side so most permanent failures are decoded-but-corrupt files (rare).
//!
//! The watcher thread isn't in any pre-existing async context, so we
//! build a short-lived tokio runtime per pass and `block_on` the join
//! of K encode futures. Concurrency knob: `CLIP_ENCODE_CONCURRENCY`
//! (default 4 — lower than faces because Apollo's CLIP path doesn't
//! release the GIL between preprocess and forward as cleanly).
use crate::ai::clip_client::{ClipClient, ClipError, EncodeImageMeta};
use crate::database::ExifDao;
use crate::exif;
use crate::file_types;
use crate::libraries::Library;
use crate::memories::PathExcluder;
use log::{debug, info, warn};
use std::path::Path;
use std::sync::{Arc, Mutex};
use tokio::sync::Semaphore;
/// One file the watcher would like to CLIP-encode. Built from the DAO
/// `list_clip_unencoded_candidates` result — needs the `content_hash`
/// for traceability in Apollo's log lines, even though the embedding
/// itself is keyed on `(library_id, rel_path)` for the back-write.
#[derive(Debug, Clone)]
pub struct ClipCandidate {
pub rel_path: String,
pub content_hash: String,
}
/// Synchronous entry point. Returns once every candidate has been
/// processed (or definitively skipped). No-op when the client is
/// disabled so the caller can call unconditionally.
pub fn run_clip_encoding_pass(
library: &Library,
excluded_dirs: &[String],
clip_client: &ClipClient,
exif_dao: Arc<Mutex<Box<dyn ExifDao>>>,
candidates: Vec<ClipCandidate>,
) {
if !clip_client.is_enabled() {
return;
}
if candidates.is_empty() {
return;
}
let base = Path::new(&library.root_path);
let filtered = filter_excluded(base, excluded_dirs, candidates, Some(&library.name));
if filtered.is_empty() {
return;
}
let concurrency: usize = std::env::var("CLIP_ENCODE_CONCURRENCY")
.ok()
.and_then(|s| s.parse().ok())
.filter(|n: &usize| *n > 0)
.unwrap_or(4);
info!(
"clip_watch: encoding {} candidate(s) for library '{}' (concurrency {})",
filtered.len(),
library.name,
concurrency
);
let rt = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
warn!("clip_watch: failed to build tokio runtime: {e}");
return;
}
};
let library_id = library.id;
let library_root = library.root_path.clone();
rt.block_on(async move {
let sem = Arc::new(Semaphore::new(concurrency));
let mut handles = Vec::with_capacity(filtered.len());
for cand in filtered {
let permit_sem = sem.clone();
let clip_client = clip_client.clone();
let exif_dao = exif_dao.clone();
let library_root = library_root.clone();
handles.push(tokio::spawn(async move {
let _permit = permit_sem.acquire().await.expect("clip semaphore");
process_one(library_id, &library_root, cand, &clip_client, exif_dao).await;
}));
}
for h in handles {
let _ = h.await;
}
});
}
async fn process_one(
library_id: i32,
library_root: &str,
cand: ClipCandidate,
clip_client: &ClipClient,
exif_dao: Arc<Mutex<Box<dyn ExifDao>>>,
) {
let abs = Path::new(library_root).join(&cand.rel_path);
let bytes = match read_image_bytes_for_encode(&abs) {
Ok(b) => b,
Err(e) => {
// Same rationale as face_watch: don't mark — the file may
// have been moved/renamed mid-scan; let the next pass retry.
warn!(
"clip_watch: read failed for {} (lib {}): {}",
cand.rel_path, library_id, e
);
return;
}
};
let meta = EncodeImageMeta {
content_hash: cand.content_hash.clone(),
library_id,
rel_path: cand.rel_path.clone(),
};
let ctx = opentelemetry::Context::current();
match clip_client.encode_image(bytes, meta).await {
Ok(resp) => {
let emb_bytes = match resp.decode_embedding() {
Ok(b) => b,
Err(e) => {
warn!("clip_watch: bad embedding for {}: {:?}", cand.rel_path, e);
return;
}
};
let mut dao = exif_dao.lock().expect("exif dao");
if let Err(e) = dao.backfill_clip_embedding(
&ctx,
library_id,
&cand.rel_path,
&emb_bytes,
&resp.model_version,
) {
warn!(
"clip_watch: backfill_clip_embedding failed for {}: {:?}",
cand.rel_path, e
);
return;
}
debug!(
"clip_watch: {} → dim={} ({}ms, {})",
cand.rel_path, resp.embedding_dim, resp.duration_ms, resp.model_version
);
}
Err(ClipError::Permanent(e)) => {
// No marker — the row sits with NULL embedding and the drain
// retries next pass. For personal-library scale the cost of
// re-attempting permanently-broken files is bounded by the
// per-tick cap. If this becomes a recurring noise source,
// add a `clip_status` column with `failed` semantics like
// face_detections has.
warn!(
"clip_watch: permanent failure on {} (will retry next pass): {}",
cand.rel_path, e
);
}
Err(ClipError::Transient(e)) => {
debug!(
"clip_watch: transient on {}: {} (will retry next pass)",
cand.rel_path, e
);
}
Err(ClipError::Disabled) => {
// Defensive — the entry-point already checked is_enabled().
}
}
}
/// Drop candidates whose paths land in an excluded dir or whose
/// extension isn't an image. Mirrors `face_watch::filter_excluded` so
/// the two backlogs stay shape-consistent. Library name is passed
/// purely for the log line that surfaces an exclusion hit.
pub fn filter_excluded(
base: &Path,
excluded_dirs: &[String],
candidates: Vec<ClipCandidate>,
library_name: Option<&str>,
) -> Vec<ClipCandidate> {
let excluder = if excluded_dirs.is_empty() {
None
} else {
Some(PathExcluder::new(base, excluded_dirs))
};
candidates
.into_iter()
.filter(|c| {
let abs = base.join(&c.rel_path);
if !file_types::is_image_file(&abs) {
debug!(
"clip_watch: skipping non-image '{}' (lib {})",
c.rel_path,
library_name.unwrap_or("<unknown>")
);
return false;
}
if let Some(ex) = excluder.as_ref()
&& ex.is_excluded(&abs)
{
debug!(
"clip_watch: skipping excluded '{}' (lib {})",
c.rel_path,
library_name.unwrap_or("<unknown>")
);
return false;
}
true
})
.collect()
}
/// Read image bytes for CLIP encoding. Same logic as
/// `face_watch::read_image_bytes_for_detect` — RAW / HEIC files don't
/// decode in Apollo's PIL pipeline, so we pull the embedded JPEG
/// preview the thumbnail pipeline already extracts. Plain JPEG / PNG /
/// WebP go through a direct read.
pub fn read_image_bytes_for_encode(path: &Path) -> std::io::Result<Vec<u8>> {
if file_types::needs_ffmpeg_thumbnail(path)
&& let Some(preview) = exif::extract_embedded_jpeg_preview(path)
{
return Ok(preview);
}
std::fs::read(path)
}
+58 -2
View File
@@ -50,15 +50,55 @@ pub fn thumbnail_path(thumbs_dir: &Path, hash: &str) -> PathBuf {
thumbs_dir.join(shard).join(format!("{}.jpg", hash)) thumbs_dir.join(shard).join(format!("{}.jpg", hash))
} }
/// Hash-keyed large-preview path: `<thumbs_dir>/_large/<hash[..2]>/<hash>.jpg`.
/// Kept under the same root as 200px thumbs so deployments don't need a
/// second env var, but namespaced under `_large/` so the existing 200px
/// shards don't collide with the larger derivative.
pub fn large_preview_path(thumbs_dir: &Path, hash: &str) -> PathBuf {
let shard = shard_prefix(hash);
thumbs_dir
.join("_large")
.join(shard)
.join(format!("{}.jpg", hash))
}
/// Hash-keyed xlarge-preview path: `<thumbs_dir>/_xlarge/<hash[..2]>/<hash>.jpg`.
pub fn xlarge_preview_path(thumbs_dir: &Path, hash: &str) -> PathBuf {
let shard = shard_prefix(hash);
thumbs_dir
.join("_xlarge")
.join(shard)
.join(format!("{}.jpg", hash))
}
/// Hash-keyed HLS output directory: `<video_dir>/<hash[..2]>/<hash>/`. /// Hash-keyed HLS output directory: `<video_dir>/<hash[..2]>/<hash>/`.
/// The playlist lives at `playlist.m3u8` inside this directory and its /// The playlist lives at `playlist.m3u8` inside this directory and its
/// segments are co-located so HLS relative references Just Work. /// segments are co-located so HLS relative references Just Work. See
#[allow(dead_code)] /// [`crate::video::hls_paths`] for the filename constants and the
/// per-file helpers built on this dir.
pub fn hls_dir(video_dir: &Path, hash: &str) -> PathBuf { pub fn hls_dir(video_dir: &Path, hash: &str) -> PathBuf {
let shard = shard_prefix(hash); let shard = shard_prefix(hash);
video_dir.join(shard).join(hash) video_dir.join(shard).join(hash)
} }
/// Library-scoped legacy mirrored path:
/// `<derivative_dir>/<library_id>/<rel_path>`. Used as the fallback when
/// `content_hash` isn't available — the library prefix prevents the
/// "lib1 wrote `vacation/IMG.jpg` first, lib2 sees thumb_path.exists()
/// and serves the wrong image" failure mode.
///
/// Existing single-library deployments may already have thumbnails at the
/// bare-legacy `<derivative_dir>/<rel_path>` shape; serving code is
/// expected to check both this scoped path and the bare-legacy path so
/// nothing 404s during the transition.
pub fn library_scoped_legacy_path(
derivative_dir: &Path,
library_id: i32,
rel_path: impl AsRef<Path>,
) -> PathBuf {
derivative_dir.join(library_id.to_string()).join(rel_path)
}
fn shard_prefix(hash: &str) -> &str { fn shard_prefix(hash: &str) -> &str {
let end = hash let end = hash
.char_indices() .char_indices()
@@ -101,8 +141,24 @@ mod tests {
let p = thumbnail_path(thumbs, "abcdef0123"); let p = thumbnail_path(thumbs, "abcdef0123");
assert_eq!(p, PathBuf::from("/tmp/thumbs/ab/abcdef0123.jpg")); assert_eq!(p, PathBuf::from("/tmp/thumbs/ab/abcdef0123.jpg"));
let l = large_preview_path(thumbs, "abcdef0123");
assert_eq!(l, PathBuf::from("/tmp/thumbs/_large/ab/abcdef0123.jpg"));
let video = Path::new("/tmp/video"); let video = Path::new("/tmp/video");
let d = hls_dir(video, "1234deadbeef"); let d = hls_dir(video, "1234deadbeef");
assert_eq!(d, PathBuf::from("/tmp/video/12/1234deadbeef")); assert_eq!(d, PathBuf::from("/tmp/video/12/1234deadbeef"));
} }
#[test]
fn library_scoped_legacy_path_prefixes_with_library_id() {
let thumbs = Path::new("/tmp/thumbs");
let p = library_scoped_legacy_path(thumbs, 7, "vacation/IMG.jpg");
assert_eq!(p, PathBuf::from("/tmp/thumbs/7/vacation/IMG.jpg"));
// Same rel_path, different library — different output. This is
// the whole point: lib 1 and lib 2 don't clobber each other.
let p1 = library_scoped_legacy_path(thumbs, 1, "vacation/IMG.jpg");
let p2 = library_scoped_legacy_path(thumbs, 2, "vacation/IMG.jpg");
assert_ne!(p1, p2);
}
} }
+24
View File
@@ -165,6 +165,15 @@ pub struct FilesRequest {
/// Optional library filter. Accepts a library id (e.g. "1") or name /// Optional library filter. Accepts a library id (e.g. "1") or name
/// (e.g. "main"). When omitted, results span all libraries. /// (e.g. "main"). When omitted, results span all libraries.
pub library: Option<String>, pub library: Option<String>,
/// When true, include rows soft-marked as duplicates of another file
/// (i.e. `image_exif.duplicate_of_hash IS NOT NULL`). Default false —
/// the standard /photos listing hides demoted siblings so the grid
/// silently shrinks after a resolve. The Apollo duplicates modal
/// passes `true` so it can show both survivors and demoted members
/// inside a group.
#[serde(default)]
pub include_duplicates: Option<bool>,
} }
#[derive(Copy, Clone, Deserialize, PartialEq, Debug)] #[derive(Copy, Clone, Deserialize, PartialEq, Debug)]
@@ -185,6 +194,8 @@ pub enum MediaType {
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum PhotoSize { pub enum PhotoSize {
Full, Full,
XLarge,
Large,
Thumb, Thumb,
} }
@@ -277,6 +288,16 @@ pub struct ExifMetadata {
pub gps: Option<GpsCoordinates>, pub gps: Option<GpsCoordinates>,
pub capture_settings: Option<CaptureSettings>, pub capture_settings: Option<CaptureSettings>,
pub date_taken: Option<i64>, pub date_taken: Option<i64>,
/// Which step of the canonical-date waterfall populated `date_taken`:
/// `"exif" | "exiftool" | "filename" | "fs_time" | "manual"`. NULL when
/// `date_taken` itself is NULL.
pub date_taken_source: Option<String>,
/// When `date_taken_source = "manual"`, the prior `date_taken` snapshot.
/// Used by the UI's revert affordance and to label "manually overridden;
/// originally X" in the details modal.
pub original_date_taken: Option<i64>,
/// When `date_taken_source = "manual"`, the prior source.
pub original_date_taken_source: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -361,6 +382,9 @@ impl From<ImageExif> for ExifMetadata {
None None
}, },
date_taken: exif.date_taken, date_taken: exif.date_taken,
date_taken_source: exif.date_taken_source,
original_date_taken: exif.original_date_taken,
original_date_taken_source: exif.original_date_taken_source,
} }
} }
} }
+20 -17
View File
@@ -222,11 +222,12 @@ impl CalendarEventDao for SqliteCalendarEventDao {
// Validate embedding dimensions if provided // Validate embedding dimensions if provided
if let Some(ref emb) = event.embedding if let Some(ref emb) = event.embedding
&& emb.len() != 768 && emb.len() != crate::ai::embedding_dim()
{ {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Invalid embedding dimensions: {} (expected 768)", "Invalid embedding dimensions: {} (expected {})",
emb.len() emb.len(),
crate::ai::embedding_dim()
)); ));
} }
@@ -274,7 +275,7 @@ impl CalendarEventDao for SqliteCalendarEventDao {
source_file: event.source_file, source_file: event.source_file,
}) })
}) })
.map_err(|_| DbError::new(DbErrorKind::InsertError)) .map_err(|e| DbError::log(DbErrorKind::InsertError, e))
} }
fn store_events_batch( fn store_events_batch(
@@ -293,7 +294,7 @@ impl CalendarEventDao for SqliteCalendarEventDao {
for event in events { for event in events {
// Validate embedding if provided // Validate embedding if provided
if let Some(ref emb) = event.embedding if let Some(ref emb) = event.embedding
&& emb.len() != 768 && emb.len() != crate::ai::embedding_dim()
{ {
log::warn!( log::warn!(
"Skipping event with invalid embedding dimensions: {}", "Skipping event with invalid embedding dimensions: {}",
@@ -348,7 +349,7 @@ impl CalendarEventDao for SqliteCalendarEventDao {
Ok(inserted) Ok(inserted)
}) })
.map_err(|_| DbError::new(DbErrorKind::InsertError)) .map_err(|e| DbError::log(DbErrorKind::InsertError, e))
} }
fn find_events_in_range( fn find_events_in_range(
@@ -373,7 +374,7 @@ impl CalendarEventDao for SqliteCalendarEventDao {
.map(|rows| rows.into_iter().map(|r| r.to_calendar_event()).collect()) .map(|rows| rows.into_iter().map(|r| r.to_calendar_event()).collect())
.map_err(|e| anyhow::anyhow!("Query error: {:?}", e)) .map_err(|e| anyhow::anyhow!("Query error: {:?}", e))
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn find_similar_events( fn find_similar_events(
@@ -385,10 +386,11 @@ impl CalendarEventDao for SqliteCalendarEventDao {
trace_db_call(context, "query", "find_similar_events", |_span| { trace_db_call(context, "query", "find_similar_events", |_span| {
let mut conn = self.connection.lock().expect("Unable to get CalendarEventDao"); let mut conn = self.connection.lock().expect("Unable to get CalendarEventDao");
if query_embedding.len() != 768 { if query_embedding.len() != crate::ai::embedding_dim() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Invalid query embedding dimensions: {} (expected 768)", "Invalid query embedding dimensions: {} (expected {})",
query_embedding.len() query_embedding.len(),
crate::ai::embedding_dim()
)); ));
} }
@@ -429,7 +431,7 @@ impl CalendarEventDao for SqliteCalendarEventDao {
Ok(scored_events.into_iter().take(limit).map(|(_, event)| event).collect()) Ok(scored_events.into_iter().take(limit).map(|(_, event)| event).collect())
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn find_relevant_events_hybrid( fn find_relevant_events_hybrid(
@@ -461,10 +463,11 @@ impl CalendarEventDao for SqliteCalendarEventDao {
// Step 2: If query embedding provided, rank by semantic similarity // Step 2: If query embedding provided, rank by semantic similarity
if let Some(query_emb) = query_embedding { if let Some(query_emb) = query_embedding {
if query_emb.len() != 768 { if query_emb.len() != crate::ai::embedding_dim() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Invalid query embedding dimensions: {} (expected 768)", "Invalid query embedding dimensions: {} (expected {})",
query_emb.len() query_emb.len(),
crate::ai::embedding_dim()
)); ));
} }
@@ -500,7 +503,7 @@ impl CalendarEventDao for SqliteCalendarEventDao {
Ok(events_in_range.into_iter().take(limit).map(|r| r.to_calendar_event()).collect()) Ok(events_in_range.into_iter().take(limit).map(|r| r.to_calendar_event()).collect())
} }
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn event_exists( fn event_exists(
@@ -528,7 +531,7 @@ impl CalendarEventDao for SqliteCalendarEventDao {
Ok(result.count > 0) Ok(result.count > 0)
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn get_event_count(&mut self, context: &opentelemetry::Context) -> Result<i64, DbError> { fn get_event_count(&mut self, context: &opentelemetry::Context) -> Result<i64, DbError> {
@@ -551,6 +554,6 @@ impl CalendarEventDao for SqliteCalendarEventDao {
Ok(result.count) Ok(result.count)
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
} }
+46 -14
View File
@@ -75,6 +75,11 @@ pub trait DailySummaryDao: Sync + Send {
context: &opentelemetry::Context, context: &opentelemetry::Context,
contact: &str, contact: &str,
) -> Result<i64, DbError>; ) -> Result<i64, DbError>;
/// Cheap presence check — returns true iff at least one daily summary row
/// exists. Used by gating logic that only needs "is the table empty?",
/// avoiding a `COUNT(*)` full scan on large corpora.
fn has_any_summaries(&mut self, context: &opentelemetry::Context) -> Result<bool, DbError>;
} }
pub struct SqliteDailySummaryDao { pub struct SqliteDailySummaryDao {
@@ -145,10 +150,11 @@ impl DailySummaryDao for SqliteDailySummaryDao {
.expect("Unable to get DailySummaryDao"); .expect("Unable to get DailySummaryDao");
// Validate embedding dimensions // Validate embedding dimensions
if summary.embedding.len() != 768 { if summary.embedding.len() != crate::ai::embedding_dim() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Invalid embedding dimensions: {} (expected 768)", "Invalid embedding dimensions: {} (expected {})",
summary.embedding.len() summary.embedding.len(),
crate::ai::embedding_dim()
)); ));
} }
@@ -185,7 +191,7 @@ impl DailySummaryDao for SqliteDailySummaryDao {
model_version: summary.model_version, model_version: summary.model_version,
}) })
}) })
.map_err(|_| DbError::new(DbErrorKind::InsertError)) .map_err(|e| DbError::log(DbErrorKind::InsertError, e))
} }
fn find_similar_summaries( fn find_similar_summaries(
@@ -197,10 +203,11 @@ impl DailySummaryDao for SqliteDailySummaryDao {
trace_db_call(context, "query", "find_similar_summaries", |_span| { trace_db_call(context, "query", "find_similar_summaries", |_span| {
let mut conn = self.connection.lock().expect("Unable to get DailySummaryDao"); let mut conn = self.connection.lock().expect("Unable to get DailySummaryDao");
if query_embedding.len() != 768 { if query_embedding.len() != crate::ai::embedding_dim() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Invalid query embedding dimensions: {} (expected 768)", "Invalid query embedding dimensions: {} (expected {})",
query_embedding.len() query_embedding.len(),
crate::ai::embedding_dim()
)); ));
} }
@@ -281,7 +288,7 @@ impl DailySummaryDao for SqliteDailySummaryDao {
Ok(top_results) Ok(top_results)
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn find_similar_summaries_with_time_weight( fn find_similar_summaries_with_time_weight(
@@ -294,10 +301,11 @@ impl DailySummaryDao for SqliteDailySummaryDao {
trace_db_call(context, "query", "find_similar_summaries_with_time_weight", |_span| { trace_db_call(context, "query", "find_similar_summaries_with_time_weight", |_span| {
let mut conn = self.connection.lock().expect("Unable to get DailySummaryDao"); let mut conn = self.connection.lock().expect("Unable to get DailySummaryDao");
if query_embedding.len() != 768 { if query_embedding.len() != crate::ai::embedding_dim() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Invalid query embedding dimensions: {} (expected 768)", "Invalid query embedding dimensions: {} (expected {})",
query_embedding.len() query_embedding.len(),
crate::ai::embedding_dim()
)); ));
} }
@@ -403,7 +411,7 @@ impl DailySummaryDao for SqliteDailySummaryDao {
Ok(top_results) Ok(top_results)
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn summary_exists( fn summary_exists(
@@ -430,7 +438,7 @@ impl DailySummaryDao for SqliteDailySummaryDao {
Ok(count > 0) Ok(count > 0)
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn get_summary_count( fn get_summary_count(
@@ -452,7 +460,31 @@ impl DailySummaryDao for SqliteDailySummaryDao {
.map(|r| r.count) .map(|r| r.count)
.map_err(|e| anyhow::anyhow!("Count query error: {:?}", e)) .map_err(|e| anyhow::anyhow!("Count query error: {:?}", e))
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn has_any_summaries(&mut self, context: &opentelemetry::Context) -> Result<bool, DbError> {
trace_db_call(context, "query", "has_any_summaries", |_span| {
let mut conn = self
.connection
.lock()
.expect("Unable to get DailySummaryDao");
#[derive(QueryableByName)]
struct ProbeResult {
#[diesel(sql_type = diesel::sql_types::Integer)]
#[allow(dead_code)]
one: i32,
}
let rows: Vec<ProbeResult> =
diesel::sql_query("SELECT 1 as one FROM daily_conversation_summaries LIMIT 1")
.load(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Failed to probe daily summaries: {}", e))?;
Ok(!rows.is_empty())
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
} }
+681
View File
@@ -0,0 +1,681 @@
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use std::ops::DerefMut;
use std::sync::{Arc, Mutex};
use crate::database::models::{
InsertInsightGenerationJob, InsightGenerationJob, InsightGenerationType, InsightJobStatus,
};
use crate::database::schema;
use crate::database::{DbError, DbErrorKind, connect};
use crate::otel::trace_db_call;
/// Tracks async insight generation jobs. Each call to `create_job` inserts
/// a new row; the application layer prevents concurrent running jobs by
/// cancelling the old one before creating a new one.
pub trait InsightGenerationJobDao: Sync + Send {
/// Insert a new running job. Always creates a new row (no upsert).
/// Cleans up terminal-state rows for the same key first.
fn create_job(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
file_path: &str,
generation_type: InsightGenerationType,
) -> Result<i32, DbError>;
/// Mark a job as completed with the resulting insight id. Only updates
/// if the job is still in "running" status (prevents overwriting a
/// cancelled job with a late-completing task).
fn complete_job(
&mut self,
context: &opentelemetry::Context,
job_id: i32,
insight_id: i32,
) -> Result<(), DbError>;
/// Mark a job as failed with an error message. Only updates if the job
/// is still in "running" status.
fn fail_job(
&mut self,
context: &opentelemetry::Context,
job_id: i32,
error_message: &str,
) -> Result<(), DbError>;
/// Cancel a specific job by id. Only updates if the job is still
/// in "running" status. Returns true if a row was updated.
fn cancel_job(
&mut self,
context: &opentelemetry::Context,
job_id: i32,
) -> Result<bool, DbError>;
/// Cancel all running jobs for a given file. Returns the number of
/// jobs cancelled.
fn cancel_active_jobs(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
file_path: &str,
) -> Result<usize, DbError>;
/// Find the latest running job for a given file. Returns None if no
/// running job exists.
fn get_active_job(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
file_path: &str,
) -> Result<Option<InsightGenerationJob>, DbError>;
/// Find any job by id regardless of status.
fn get_job_by_id(
&mut self,
context: &opentelemetry::Context,
job_id: i32,
) -> Result<Option<InsightGenerationJob>, DbError>;
/// Mark all jobs still in "running" status as "failed" with a recovery
/// error message. Returns the number of jobs recovered.
fn recover_orphaned_jobs(&mut self, context: &opentelemetry::Context)
-> Result<usize, DbError>;
}
pub struct SqliteInsightGenerationJobDao {
connection: Arc<Mutex<SqliteConnection>>,
}
impl Default for SqliteInsightGenerationJobDao {
fn default() -> Self {
Self::new()
}
}
impl SqliteInsightGenerationJobDao {
pub fn new() -> Self {
Self {
connection: Arc::new(Mutex::new(connect())),
}
}
#[cfg(test)]
pub fn from_connection(conn: Arc<Mutex<SqliteConnection>>) -> Self {
Self { connection: conn }
}
}
impl InsightGenerationJobDao for SqliteInsightGenerationJobDao {
fn create_job(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
file_path: &str,
generation_type: InsightGenerationType,
) -> Result<i32, DbError> {
trace_db_call(context, "insert", "create_job", |_span| {
use schema::insight_generation_jobs::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock InsightGenerationJobDao");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("Time went backwards")
.as_secs() as i64;
let new_job = InsertInsightGenerationJob {
library_id,
path: file_path.to_string(),
gen_type: generation_type.to_string(),
status: InsightJobStatus::Running.to_string(),
started_at: now,
};
diesel::insert_into(dsl::insight_generation_jobs)
.values(&new_job)
.execute(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Failed to insert job: {}", e))?;
dsl::insight_generation_jobs
.filter(
dsl::library_id
.eq(library_id)
.and(dsl::file_path.eq(file_path))
.and(dsl::generation_type.eq(generation_type.as_str()))
.and(dsl::status.eq(InsightJobStatus::Running.as_str())),
)
.select(dsl::id)
.order(dsl::id.desc())
.first::<i32>(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Failed to get job id: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn complete_job(
&mut self,
context: &opentelemetry::Context,
job_id: i32,
insight_id: i32,
) -> Result<(), DbError> {
trace_db_call(context, "update", "complete_job", |_span| {
use schema::insight_generation_jobs::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock InsightGenerationJobDao");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("Time went backwards")
.as_secs() as i64;
// Only update if still running — prevents cancelled job from
// being overwritten by a late-completing task.
diesel::update(
dsl::insight_generation_jobs.filter(
dsl::id
.eq(job_id)
.and(dsl::status.eq(InsightJobStatus::Running.as_str())),
),
)
.set((
dsl::status.eq(InsightJobStatus::Completed.as_str()),
dsl::completed_at.eq(Some(now)),
dsl::result_insight_id.eq(Some(insight_id)),
))
.execute(connection.deref_mut())
.map(|_| ())
.map_err(|e| anyhow::anyhow!("Failed to complete job: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
}
fn fail_job(
&mut self,
context: &opentelemetry::Context,
job_id: i32,
error_message: &str,
) -> Result<(), DbError> {
trace_db_call(context, "update", "fail_job", |_span| {
use schema::insight_generation_jobs::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock InsightGenerationJobDao");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("Time went backwards")
.as_secs() as i64;
// Only update if still running.
diesel::update(
dsl::insight_generation_jobs.filter(
dsl::id
.eq(job_id)
.and(dsl::status.eq(InsightJobStatus::Running.as_str())),
),
)
.set((
dsl::status.eq(InsightJobStatus::Failed.as_str()),
dsl::completed_at.eq(Some(now)),
dsl::error_message.eq(Some(error_message.to_string())),
))
.execute(connection.deref_mut())
.map(|_| ())
.map_err(|e| anyhow::anyhow!("Failed to fail job: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
}
fn cancel_job(
&mut self,
context: &opentelemetry::Context,
job_id: i32,
) -> Result<bool, DbError> {
trace_db_call(context, "update", "cancel_job", |_span| {
use schema::insight_generation_jobs::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock InsightGenerationJobDao");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("Time went backwards")
.as_secs() as i64;
let rows = diesel::update(
dsl::insight_generation_jobs.filter(
dsl::id
.eq(job_id)
.and(dsl::status.eq(InsightJobStatus::Running.as_str())),
),
)
.set((
dsl::status.eq(InsightJobStatus::Cancelled.as_str()),
dsl::completed_at.eq(Some(now)),
dsl::error_message.eq(Some("cancelled by user".to_string())),
))
.execute(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Failed to cancel job: {}", e))?;
Ok(rows > 0)
})
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
}
fn cancel_active_jobs(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
file_path: &str,
) -> Result<usize, DbError> {
trace_db_call(context, "update", "cancel_active_jobs", |_span| {
use schema::insight_generation_jobs::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock InsightGenerationJobDao");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("Time went backwards")
.as_secs() as i64;
let rows = diesel::update(
dsl::insight_generation_jobs.filter(
dsl::library_id
.eq(library_id)
.and(dsl::file_path.eq(file_path))
.and(dsl::status.eq(InsightJobStatus::Running.as_str())),
),
)
.set((
dsl::status.eq(InsightJobStatus::Cancelled.as_str()),
dsl::completed_at.eq(Some(now)),
dsl::error_message.eq(Some("cancelled by newer request".to_string())),
))
.execute(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Failed to cancel active jobs: {}", e))?;
Ok(rows)
})
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
}
fn get_active_job(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
file_path: &str,
) -> Result<Option<InsightGenerationJob>, DbError> {
trace_db_call(context, "query", "get_active_job", |_span| {
use schema::insight_generation_jobs::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock InsightGenerationJobDao");
dsl::insight_generation_jobs
.filter(
dsl::library_id
.eq(library_id)
.and(dsl::file_path.eq(file_path))
.and(dsl::status.eq(InsightJobStatus::Running.as_str())),
)
.order(dsl::id.desc())
.first::<InsightGenerationJob>(connection.deref_mut())
.optional()
.map_err(|e| anyhow::anyhow!("Failed to get active job: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn get_job_by_id(
&mut self,
context: &opentelemetry::Context,
job_id: i32,
) -> Result<Option<InsightGenerationJob>, DbError> {
trace_db_call(context, "query", "get_job_by_id", |_span| {
use schema::insight_generation_jobs::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock InsightGenerationJobDao");
dsl::insight_generation_jobs
.filter(dsl::id.eq(job_id))
.first::<InsightGenerationJob>(connection.deref_mut())
.optional()
.map_err(|e| anyhow::anyhow!("Failed to get job: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn recover_orphaned_jobs(
&mut self,
context: &opentelemetry::Context,
) -> Result<usize, DbError> {
trace_db_call(context, "update", "recover_orphaned_jobs", |_span| {
use schema::insight_generation_jobs::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock InsightGenerationJobDao");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("Time went backwards")
.as_secs() as i64;
let rows = diesel::update(
dsl::insight_generation_jobs
.filter(dsl::status.eq(InsightJobStatus::Running.as_str())),
)
.set((
dsl::status.eq(InsightJobStatus::Failed.as_str()),
dsl::completed_at.eq(Some(now)),
dsl::error_message.eq(Some("server crashed while running".to_string())),
))
.execute(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Failed to recover orphaned jobs: {}", e))?;
Ok(rows)
})
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
}
}
#[cfg(test)]
mod tests {
use super::*;
use diesel::Connection;
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!();
fn setup_dao() -> SqliteInsightGenerationJobDao {
let mut conn = SqliteConnection::establish(":memory:")
.expect("Unable to create in-memory db connection");
conn.run_pending_migrations(DB_MIGRATIONS)
.expect("Failure running DB migrations");
SqliteInsightGenerationJobDao::from_connection(Arc::new(Mutex::new(conn)))
}
fn ctx() -> opentelemetry::Context {
opentelemetry::Context::new()
}
#[test]
fn create_job_inserts_new_row() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id_1 = dao
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
let job_id_2 = dao
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
assert_ne!(job_id_1, job_id_2, "each create_job call inserts a new row");
}
#[test]
fn complete_job_sets_result() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id = dao
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
dao.complete_job(&ctx, job_id, 42).unwrap();
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
assert_eq!(job.status, InsightJobStatus::Completed.as_str());
assert_eq!(job.result_insight_id, Some(42));
assert!(job.completed_at.is_some());
}
#[test]
fn fail_job_sets_error() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id = dao
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Agentic)
.unwrap();
dao.fail_job(&ctx, job_id, "model timeout").unwrap();
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
assert_eq!(job.status, InsightJobStatus::Failed.as_str());
assert_eq!(job.error_message.as_deref(), Some("model timeout"));
assert!(job.completed_at.is_some());
}
#[test]
fn get_active_job_returns_none_when_completed() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id = dao
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
// Job is running
let active = dao.get_active_job(&ctx, 1, "photos/test.jpg").unwrap();
assert!(active.is_some());
assert_eq!(active.unwrap().id, job_id);
// Complete it
dao.complete_job(&ctx, job_id, 1).unwrap();
// No longer active
let active = dao.get_active_job(&ctx, 1, "photos/test.jpg").unwrap();
assert!(active.is_none());
}
#[test]
fn cancel_active_jobs() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id = dao
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
let cancelled = dao.cancel_active_jobs(&ctx, 1, "photos/test.jpg").unwrap();
assert_eq!(cancelled, 1, "should cancel 1 running job");
// Job is no longer active
let active = dao.get_active_job(&ctx, 1, "photos/test.jpg").unwrap();
assert!(active.is_none());
// Job exists with cancelled status
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
assert_eq!(job.status, InsightJobStatus::Cancelled.as_str());
// Cancelling again returns 0 (nothing to cancel)
let cancelled2 = dao.cancel_active_jobs(&ctx, 1, "photos/test.jpg").unwrap();
assert_eq!(cancelled2, 0, "should return 0 when no running job");
}
#[test]
fn get_active_job_scoped_by_library() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id_1 = dao
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
let job_id_2 = dao
.create_job(&ctx, 2, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
assert_ne!(
job_id_1, job_id_2,
"different libraries should have separate jobs"
);
// Complete lib1's job
dao.complete_job(&ctx, job_id_1, 1).unwrap();
// lib1 has no active job
let active1 = dao.get_active_job(&ctx, 1, "photos/test.jpg").unwrap();
assert!(active1.is_none());
// lib2 still has active job
let active2 = dao.get_active_job(&ctx, 2, "photos/test.jpg").unwrap();
assert!(active2.is_some());
assert_eq!(active2.unwrap().id, job_id_2);
}
#[test]
fn get_job_by_id_finds_any_status() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id = dao
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
// Find while running
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
assert_eq!(job.status, InsightJobStatus::Running.as_str());
// Complete it
dao.complete_job(&ctx, job_id, 99).unwrap();
// Still findable
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
assert_eq!(job.status, InsightJobStatus::Completed.as_str());
assert_eq!(job.result_insight_id, Some(99));
}
#[test]
fn recover_orphaned_jobs() {
let mut dao = setup_dao();
let ctx = ctx();
// Create two running jobs
let job_id_1 = dao
.create_job(&ctx, 1, "photos/a.jpg", InsightGenerationType::Standard)
.unwrap();
let job_id_2 = dao
.create_job(&ctx, 1, "photos/b.jpg", InsightGenerationType::Agentic)
.unwrap();
// Complete one
dao.complete_job(&ctx, job_id_1, 1).unwrap();
// Recover should only affect the running job
let recovered = dao.recover_orphaned_jobs(&ctx).unwrap();
assert_eq!(recovered, 1, "should recover exactly 1 running job");
// job_id_1 is still completed
let job1 = dao.get_job_by_id(&ctx, job_id_1).unwrap().unwrap();
assert_eq!(job1.status, InsightJobStatus::Completed.as_str());
// job_id_2 is now failed with recovery message
let job2 = dao.get_job_by_id(&ctx, job_id_2).unwrap().unwrap();
assert_eq!(job2.status, InsightJobStatus::Failed.as_str());
assert_eq!(
job2.error_message.as_deref(),
Some("server crashed while running")
);
// Second recovery is a no-op
let recovered2 = dao.recover_orphaned_jobs(&ctx).unwrap();
assert_eq!(recovered2, 0, "no running jobs remain");
}
#[test]
fn complete_job_noop_when_cancelled() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id = dao
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
dao.cancel_job(&ctx, job_id).unwrap();
// Late-completing task tries to mark as completed — should be a no-op
dao.complete_job(&ctx, job_id, 42).unwrap();
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
assert_eq!(
job.status,
InsightJobStatus::Cancelled.as_str(),
"cancelled status must not be overwritten by late complete"
);
assert_eq!(
job.result_insight_id, None,
"insight_id must stay None when complete is a no-op"
);
}
#[test]
fn fail_job_noop_when_cancelled() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id = dao
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Agentic)
.unwrap();
dao.cancel_job(&ctx, job_id).unwrap();
// Late-failing task tries to mark as failed — should be a no-op
dao.fail_job(&ctx, job_id, "timeout after 120s").unwrap();
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
assert_eq!(
job.status,
InsightJobStatus::Cancelled.as_str(),
"cancelled status must not be overwritten by late fail"
);
assert_eq!(
job.error_message.as_deref(),
Some("cancelled by user"),
"error_message must reflect the cancel, not the late fail"
);
}
#[test]
fn cancel_job_by_id() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id = dao
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
let cancelled = dao.cancel_job(&ctx, job_id).unwrap();
assert!(cancelled, "should cancel running job");
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
assert_eq!(job.status, InsightJobStatus::Cancelled.as_str());
assert!(job.completed_at.is_some());
// Cancelling again is a no-op
let cancelled2 = dao.cancel_job(&ctx, job_id).unwrap();
assert!(!cancelled2, "already cancelled job should return false");
}
}
+208 -27
View File
@@ -21,6 +21,22 @@ pub trait InsightDao: Sync + Send {
file_path: &str, file_path: &str,
) -> Result<Option<PhotoInsight>, DbError>; ) -> Result<Option<PhotoInsight>, DbError>;
/// Library-scoped variant of `get_insight`. The default `get_insight`
/// finds any `is_current=true` row matching `file_path` across
/// libraries — fine for the photo-grid metadata fetch (cross-library
/// merge), wrong for the chat path: a regenerate on lib1 flips lib1's
/// row to `is_current=false` and inserts a new lib1 row, but
/// lib2's untouched `is_current=true` row for the same rel_path
/// would still satisfy the path-only query and shadow the regen on
/// the next history fetch. Always pass a library_id when you have
/// one (chat / insight write paths always do).
fn get_current_insight_for_library(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
file_path: &str,
) -> Result<Option<PhotoInsight>, DbError>;
/// Return the most recent current insight whose rel_path is one of /// Return the most recent current insight whose rel_path is one of
/// `paths`. Used for content-hash sharing: the caller expands a /// `paths`. Used for content-hash sharing: the caller expands a
/// single file into all rel_paths with the same content_hash, then /// single file into all rel_paths with the same content_hash, then
@@ -31,7 +47,6 @@ pub trait InsightDao: Sync + Send {
paths: &[String], paths: &[String],
) -> Result<Option<PhotoInsight>, DbError>; ) -> Result<Option<PhotoInsight>, DbError>;
#[allow(dead_code)]
fn get_insight_history( fn get_insight_history(
&mut self, &mut self,
context: &opentelemetry::Context, context: &opentelemetry::Context,
@@ -66,6 +81,17 @@ pub trait InsightDao: Sync + Send {
approved: bool, approved: bool,
) -> Result<(), DbError>; ) -> Result<(), DbError>;
/// Rate a specific insight version by primary key, regardless of
/// `is_current`. Used by the per-file history view to approve/reject
/// previously generated (superseded) versions, which the path-based
/// `rate_insight` (current row only) cannot reach.
fn rate_insight_by_id(
&mut self,
context: &opentelemetry::Context,
insight_id: i32,
approved: bool,
) -> Result<(), DbError>;
fn get_approved_insights( fn get_approved_insights(
&mut self, &mut self,
context: &opentelemetry::Context, context: &opentelemetry::Context,
@@ -74,13 +100,15 @@ pub trait InsightDao: Sync + Send {
/// Replace the `training_messages` JSON blob on the current row for /// Replace the `training_messages` JSON blob on the current row for
/// `(library_id, rel_path)`. Used by chat-turn append mode to persist /// `(library_id, rel_path)`. Used by chat-turn append mode to persist
/// the extended conversation without inserting a new insight version. /// the extended conversation without inserting a new insight version.
/// Returns the number of rows affected (0 if no current row matched,
/// indicating a concurrent regenerate/reconcile flipped `is_current`).
fn update_training_messages( fn update_training_messages(
&mut self, &mut self,
context: &opentelemetry::Context, context: &opentelemetry::Context,
library_id: i32, library_id: i32,
file_path: &str, file_path: &str,
training_messages_json: &str, training_messages_json: &str,
) -> Result<(), DbError>; ) -> Result<usize, DbError>;
} }
pub struct SqliteInsightDao { pub struct SqliteInsightDao {
@@ -111,13 +139,30 @@ impl InsightDao for SqliteInsightDao {
fn store_insight( fn store_insight(
&mut self, &mut self,
context: &opentelemetry::Context, context: &opentelemetry::Context,
insight: InsertPhotoInsight, mut insight: InsertPhotoInsight,
) -> Result<PhotoInsight, DbError> { ) -> Result<PhotoInsight, DbError> {
trace_db_call(context, "insert", "store_insight", |_span| { trace_db_call(context, "insert", "store_insight", |_span| {
use schema::photo_insights::dsl::*; use schema::photo_insights::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get InsightDao"); let mut connection = self.connection.lock().expect("Unable to get InsightDao");
// Eagerly populate content_hash so this insight follows the
// bytes (CLAUDE.md "Multi-library data model"). Caller-
// supplied hash wins; otherwise look it up from image_exif
// for the (library_id, rel_path) tuple. None is acceptable —
// reconciliation backfills it once the hash lands.
if insight.content_hash.is_none() {
use schema::image_exif as ie;
insight.content_hash = ie::table
.filter(ie::library_id.eq(insight.library_id))
.filter(ie::rel_path.eq(&insight.file_path))
.filter(ie::content_hash.is_not_null())
.select(ie::content_hash)
.first::<Option<String>>(connection.deref_mut())
.ok()
.flatten();
}
// Mark all existing insights for this file as no longer current // Mark all existing insights for this file as no longer current
diesel::update( diesel::update(
photo_insights photo_insights
@@ -126,13 +171,13 @@ impl InsightDao for SqliteInsightDao {
) )
.set(is_current.eq(false)) .set(is_current.eq(false))
.execute(connection.deref_mut()) .execute(connection.deref_mut())
.map_err(|_| anyhow::anyhow!("Update is_current error"))?; .map_err(|e| anyhow::anyhow!("Failed to flip is_current: {}", e))?;
// Insert the new insight as current // Insert the new insight as current
diesel::insert_into(photo_insights) diesel::insert_into(photo_insights)
.values(&insight) .values(&insight)
.execute(connection.deref_mut()) .execute(connection.deref_mut())
.map_err(|_| anyhow::anyhow!("Insert error"))?; .map_err(|e| anyhow::anyhow!("Failed to insert insight: {}", e))?;
// Retrieve the inserted record (is_current = true) // Retrieve the inserted record (is_current = true)
photo_insights photo_insights
@@ -140,9 +185,12 @@ impl InsightDao for SqliteInsightDao {
.filter(rel_path.eq(&insight.file_path)) .filter(rel_path.eq(&insight.file_path))
.filter(is_current.eq(true)) .filter(is_current.eq(true))
.first::<PhotoInsight>(connection.deref_mut()) .first::<PhotoInsight>(connection.deref_mut())
.map_err(|_| anyhow::anyhow!("Query error")) .map_err(|e| anyhow::anyhow!("Failed to retrieve inserted insight: {}", e))
})
.map_err(|e| {
log::error!("store_insight failed: {}", e);
DbError::new(DbErrorKind::InsertError)
}) })
.map_err(|_| DbError::new(DbErrorKind::InsertError))
} }
fn get_insight( fn get_insight(
@@ -160,9 +208,36 @@ impl InsightDao for SqliteInsightDao {
.filter(is_current.eq(true)) .filter(is_current.eq(true))
.first::<PhotoInsight>(connection.deref_mut()) .first::<PhotoInsight>(connection.deref_mut())
.optional() .optional()
.map_err(|_| anyhow::anyhow!("Query error")) .map_err(|e| anyhow::anyhow!("Query error: {}", e))
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn get_current_insight_for_library(
&mut self,
context: &opentelemetry::Context,
lib_id: i32,
path: &str,
) -> Result<Option<PhotoInsight>, DbError> {
trace_db_call(
context,
"query",
"get_current_insight_for_library",
|_span| {
use schema::photo_insights::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get InsightDao");
photo_insights
.filter(library_id.eq(lib_id))
.filter(rel_path.eq(path))
.filter(is_current.eq(true))
.first::<PhotoInsight>(connection.deref_mut())
.optional()
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
},
)
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn get_insight_for_paths( fn get_insight_for_paths(
@@ -184,9 +259,9 @@ impl InsightDao for SqliteInsightDao {
.order(generated_at.desc()) .order(generated_at.desc())
.first::<PhotoInsight>(connection.deref_mut()) .first::<PhotoInsight>(connection.deref_mut())
.optional() .optional()
.map_err(|_| anyhow::anyhow!("Query error")) .map_err(|e| anyhow::anyhow!("Query error: {}", e))
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn get_insight_history( fn get_insight_history(
@@ -203,9 +278,9 @@ impl InsightDao for SqliteInsightDao {
.filter(rel_path.eq(path)) .filter(rel_path.eq(path))
.order(generated_at.desc()) .order(generated_at.desc())
.load::<PhotoInsight>(connection.deref_mut()) .load::<PhotoInsight>(connection.deref_mut())
.map_err(|_| anyhow::anyhow!("Query error")) .map_err(|e| anyhow::anyhow!("Query error: {}", e))
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn get_insight_by_id( fn get_insight_by_id(
@@ -222,9 +297,9 @@ impl InsightDao for SqliteInsightDao {
.find(insight_id) .find(insight_id)
.first::<PhotoInsight>(connection.deref_mut()) .first::<PhotoInsight>(connection.deref_mut())
.optional() .optional()
.map_err(|_| anyhow::anyhow!("Query error")) .map_err(|e| anyhow::anyhow!("Query error: {}", e))
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn delete_insight( fn delete_insight(
@@ -240,9 +315,9 @@ impl InsightDao for SqliteInsightDao {
diesel::delete(photo_insights.filter(rel_path.eq(path))) diesel::delete(photo_insights.filter(rel_path.eq(path)))
.execute(connection.deref_mut()) .execute(connection.deref_mut())
.map(|_| ()) .map(|_| ())
.map_err(|_| anyhow::anyhow!("Delete error")) .map_err(|e| anyhow::anyhow!("Delete error: {}", e))
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn get_all_insights( fn get_all_insights(
@@ -258,9 +333,9 @@ impl InsightDao for SqliteInsightDao {
.filter(is_current.eq(true)) .filter(is_current.eq(true))
.order(generated_at.desc()) .order(generated_at.desc())
.load::<PhotoInsight>(connection.deref_mut()) .load::<PhotoInsight>(connection.deref_mut())
.map_err(|_| anyhow::anyhow!("Query error")) .map_err(|e| anyhow::anyhow!("Query error: {}", e))
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn rate_insight( fn rate_insight(
@@ -282,9 +357,29 @@ impl InsightDao for SqliteInsightDao {
.set(approved.eq(Some(is_approved))) .set(approved.eq(Some(is_approved)))
.execute(connection.deref_mut()) .execute(connection.deref_mut())
.map(|_| ()) .map(|_| ())
.map_err(|_| anyhow::anyhow!("Update error")) .map_err(|e| anyhow::anyhow!("Update error: {}", e))
}) })
.map_err(|_| DbError::new(DbErrorKind::UpdateError)) .map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
}
fn rate_insight_by_id(
&mut self,
context: &opentelemetry::Context,
target_id: i32,
is_approved: bool,
) -> Result<(), DbError> {
trace_db_call(context, "update", "rate_insight_by_id", |_span| {
use schema::photo_insights::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get InsightDao");
diesel::update(photo_insights.find(target_id))
.set(approved.eq(Some(is_approved)))
.execute(connection.deref_mut())
.map(|_| ())
.map_err(|e| anyhow::anyhow!("Update error: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
} }
fn get_approved_insights( fn get_approved_insights(
@@ -301,9 +396,9 @@ impl InsightDao for SqliteInsightDao {
.filter(training_messages.is_not_null()) .filter(training_messages.is_not_null())
.order(generated_at.desc()) .order(generated_at.desc())
.load::<PhotoInsight>(connection.deref_mut()) .load::<PhotoInsight>(connection.deref_mut())
.map_err(|_| anyhow::anyhow!("Query error")) .map_err(|e| anyhow::anyhow!("Query error: {}", e))
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn update_training_messages( fn update_training_messages(
@@ -312,7 +407,7 @@ impl InsightDao for SqliteInsightDao {
lib_id: i32, lib_id: i32,
path: &str, path: &str,
training_messages_json: &str, training_messages_json: &str,
) -> Result<(), DbError> { ) -> Result<usize, DbError> {
trace_db_call(context, "update", "update_training_messages", |_span| { trace_db_call(context, "update", "update_training_messages", |_span| {
use schema::photo_insights::dsl::*; use schema::photo_insights::dsl::*;
@@ -326,9 +421,95 @@ impl InsightDao for SqliteInsightDao {
) )
.set(training_messages.eq(Some(training_messages_json.to_string()))) .set(training_messages.eq(Some(training_messages_json.to_string())))
.execute(connection.deref_mut()) .execute(connection.deref_mut())
.map(|_| ()) .map_err(|e| anyhow::anyhow!("Update error: {}", e))
.map_err(|_| anyhow::anyhow!("Update error"))
}) })
.map_err(|_| DbError::new(DbErrorKind::UpdateError)) .map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::test::in_memory_db_connection;
fn dao() -> SqliteInsightDao {
let conn = Arc::new(Mutex::new(in_memory_db_connection()));
SqliteInsightDao::from_connection(conn)
}
/// Build an insight insert with sensible defaults; tests override the
/// fields they care about (path, generated_at, model).
fn insert(path: &str, generated_at: i64, model: &str) -> InsertPhotoInsight {
InsertPhotoInsight {
library_id: 1,
file_path: path.to_string(),
title: format!("title for {model}"),
summary: "summary".to_string(),
generated_at,
model_version: model.to_string(),
is_current: true,
training_messages: None,
backend: "local".to_string(),
fewshot_source_ids: None,
content_hash: None,
num_ctx: None,
temperature: None,
top_p: None,
top_k: None,
min_p: None,
system_prompt: None,
persona_id: None,
prompt_eval_count: None,
eval_count: None,
}
}
#[test]
fn get_insight_history_returns_all_versions_newest_first() {
let cx = opentelemetry::Context::new();
let mut dao = dao();
// store_insight flips prior rows to is_current=false, so three
// generations for the same path leave a 3-row history.
dao.store_insight(&cx, insert("a.jpg", 100, "m1")).unwrap();
dao.store_insight(&cx, insert("a.jpg", 200, "m2")).unwrap();
dao.store_insight(&cx, insert("a.jpg", 300, "m3")).unwrap();
// A different path must not leak into the history.
dao.store_insight(&cx, insert("b.jpg", 250, "other"))
.unwrap();
let history = dao.get_insight_history(&cx, "a.jpg").unwrap();
assert_eq!(history.len(), 3);
assert_eq!(
history.iter().map(|i| i.generated_at).collect::<Vec<_>>(),
vec![300, 200, 100],
"history should be newest-first"
);
// Exactly one version is current (the latest generation).
let current: Vec<_> = history.iter().filter(|i| i.is_current).collect();
assert_eq!(current.len(), 1);
assert_eq!(current[0].generated_at, 300);
}
#[test]
fn rate_insight_by_id_rates_only_the_targeted_version() {
let cx = opentelemetry::Context::new();
let mut dao = dao();
dao.store_insight(&cx, insert("a.jpg", 100, "m1")).unwrap();
dao.store_insight(&cx, insert("a.jpg", 200, "m2")).unwrap();
// History is newest-first: [200 (current), 100 (superseded)].
let history = dao.get_insight_history(&cx, "a.jpg").unwrap();
let old_version = history.iter().find(|i| i.generated_at == 100).unwrap();
assert!(!old_version.is_current);
dao.rate_insight_by_id(&cx, old_version.id, true).unwrap();
let history = dao.get_insight_history(&cx, "a.jpg").unwrap();
let old = history.iter().find(|i| i.generated_at == 100).unwrap();
let current = history.iter().find(|i| i.generated_at == 200).unwrap();
assert_eq!(old.approved, Some(true), "targeted version is rated");
assert_eq!(current.approved, None, "current version is untouched");
} }
} }
File diff suppressed because it is too large Load Diff
+12 -11
View File
@@ -216,11 +216,12 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
// Validate embedding dimensions if provided (rare for location data) // Validate embedding dimensions if provided (rare for location data)
if let Some(ref emb) = location.embedding if let Some(ref emb) = location.embedding
&& emb.len() != 768 && emb.len() != crate::ai::embedding_dim()
{ {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Invalid embedding dimensions: {} (expected 768)", "Invalid embedding dimensions: {} (expected {})",
emb.len() emb.len(),
crate::ai::embedding_dim()
)); ));
} }
@@ -273,7 +274,7 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
source_file: location.source_file, source_file: location.source_file,
}) })
}) })
.map_err(|_| DbError::new(DbErrorKind::InsertError)) .map_err(|e| DbError::log(DbErrorKind::InsertError, e))
} }
fn store_locations_batch( fn store_locations_batch(
@@ -292,7 +293,7 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
for location in locations { for location in locations {
// Validate embedding if provided (rare) // Validate embedding if provided (rare)
if let Some(ref emb) = location.embedding if let Some(ref emb) = location.embedding
&& emb.len() != 768 && emb.len() != crate::ai::embedding_dim()
{ {
log::warn!( log::warn!(
"Skipping location with invalid embedding dimensions: {}", "Skipping location with invalid embedding dimensions: {}",
@@ -350,7 +351,7 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
Ok(inserted) Ok(inserted)
}) })
.map_err(|_| DbError::new(DbErrorKind::InsertError)) .map_err(|e| DbError::log(DbErrorKind::InsertError, e))
} }
fn find_nearest_location( fn find_nearest_location(
@@ -385,7 +386,7 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
Ok(results.into_iter().next().map(|r| r.to_location_record())) Ok(results.into_iter().next().map(|r| r.to_location_record()))
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn find_locations_in_range( fn find_locations_in_range(
@@ -413,7 +414,7 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
.map(|rows| rows.into_iter().map(|r| r.to_location_record()).collect()) .map(|rows| rows.into_iter().map(|r| r.to_location_record()).collect())
.map_err(|e| anyhow::anyhow!("Query error: {:?}", e)) .map_err(|e| anyhow::anyhow!("Query error: {:?}", e))
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn find_locations_near_point( fn find_locations_near_point(
@@ -468,7 +469,7 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
Ok(filtered) Ok(filtered)
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn location_exists( fn location_exists(
@@ -502,7 +503,7 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
Ok(result.count > 0) Ok(result.count > 0)
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn get_location_count(&mut self, context: &opentelemetry::Context) -> Result<i64, DbError> { fn get_location_count(&mut self, context: &opentelemetry::Context) -> Result<i64, DbError> {
@@ -525,6 +526,6 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
Ok(result.count) Ok(result.count)
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
} }
+1756 -54
View File
File diff suppressed because it is too large Load Diff
+315 -2
View File
@@ -1,9 +1,76 @@
use crate::database::schema::{ use crate::database::schema::{
entities, entity_facts, entity_photo_links, favorites, image_exif, libraries, photo_insights, entities, entity_facts, entity_photo_links, favorites, image_exif, insight_generation_jobs,
users, video_preview_clips, libraries, personas, photo_insights, precomputed_reels, user_ai_prefs, users,
video_preview_clips,
}; };
use serde::Serialize; use serde::Serialize;
/// Possible statuses for an insight generation job.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, FromSqlRow)]
#[serde(rename_all = "snake_case")]
pub enum InsightJobStatus {
Running,
Completed,
Failed,
Cancelled,
}
impl InsightJobStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Running => "running",
Self::Completed => "completed",
Self::Failed => "failed",
Self::Cancelled => "cancelled",
}
}
pub fn parse(s: &str) -> Self {
match s {
"running" => Self::Running,
"completed" => Self::Completed,
"failed" => Self::Failed,
"cancelled" => Self::Cancelled,
other => {
log::warn!(
"Unknown InsightJobStatus value: {:?}, treating as failed",
other
);
Self::Failed
}
}
}
}
impl std::fmt::Display for InsightJobStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// Type of insight generation (standard vs agentic).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum InsightGenerationType {
Standard,
Agentic,
}
impl InsightGenerationType {
pub fn as_str(&self) -> &'static str {
match self {
Self::Standard => "standard",
Self::Agentic => "agentic",
}
}
}
impl std::fmt::Display for InsightGenerationType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Insertable)] #[derive(Insertable)]
#[diesel(table_name = users)] #[diesel(table_name = users)]
pub struct InsertUser<'a> { pub struct InsertUser<'a> {
@@ -59,6 +126,16 @@ pub struct InsertImageExif {
pub last_modified: i64, pub last_modified: i64,
pub content_hash: Option<String>, pub content_hash: Option<String>,
pub size_bytes: Option<i64>, pub size_bytes: Option<i64>,
/// 64-bit pHash (DCT) packed as i64. NULL for videos and decode failures.
pub phash_64: Option<i64>,
/// 64-bit dHash (gradient). NULL for videos and decode failures.
pub dhash_64: Option<i64>,
/// Which step of the canonical-date waterfall populated `date_taken`:
/// `"exif"` | `"exiftool"` | `"filename"` | `"fs_time"`. NULL when
/// `date_taken` is NULL (no source resolved it). The per-tick backfill
/// drain re-resolves rows whose source is `"fs_time"` once exiftool
/// has had a chance to run.
pub date_taken_source: Option<String>,
} }
// Field order matches the post-migration column order in `image_exif`. // Field order matches the post-migration column order in `image_exif`.
@@ -86,6 +163,33 @@ pub struct ImageExif {
pub last_modified: i64, pub last_modified: i64,
pub content_hash: Option<String>, pub content_hash: Option<String>,
pub size_bytes: Option<i64>, pub size_bytes: Option<i64>,
pub phash_64: Option<i64>,
pub dhash_64: Option<i64>,
/// When non-null, this row is a soft-marked duplicate of the file
/// whose `content_hash` matches this value. The default `/photos`
/// listing filters such rows out.
pub duplicate_of_hash: Option<String>,
/// Unix seconds at which the resolve was committed.
pub duplicate_decided_at: Option<i64>,
/// Which step of the canonical-date waterfall populated `date_taken`.
/// Plus `"manual"` when the operator has set it via POST /image/exif/date.
pub date_taken_source: Option<String>,
/// Snapshot of the prior `date_taken` taken on first manual override.
/// NULL when no override is active. POST /image/exif/date/clear restores
/// `date_taken` from this column and nulls it back out.
pub original_date_taken: Option<i64>,
/// Snapshot of the prior `date_taken_source` taken on first manual
/// override. NULL when no override is active.
pub original_date_taken_source: Option<String>,
/// L2-normalized CLIP image embedding (raw little-endian float32 bytes;
/// length depends on the model — 768×4 for ViT-L/14, 512×4 for ViT-B/32).
/// NULL until Apollo's CLIP service has encoded this photo via the
/// backfill drain. Used by `/photos/search` for semantic queries.
pub clip_embedding: Option<Vec<u8>>,
/// Which CLIP model produced `clip_embedding` (e.g. `"ViT-L/14"`). A
/// swap of `APOLLO_CLIP_MODEL` re-eligibilizes rows whose stored
/// version differs so the drain rebuilds them.
pub clip_model_version: Option<String>,
} }
#[derive(Insertable)] #[derive(Insertable)]
@@ -108,6 +212,22 @@ pub struct InsertPhotoInsight {
/// generation). Used downstream to filter out contaminated rows when /// generation). Used downstream to filter out contaminated rows when
/// assembling an unbiased training / evaluation set. /// assembling an unbiased training / evaluation set.
pub fewshot_source_ids: Option<String>, pub fewshot_source_ids: Option<String>,
/// Bytes-keyed identity. When present, this insight is considered
/// to belong to the content rather than the path — see CLAUDE.md
/// "Multi-library data model". The DAO populates this from
/// `image_exif.content_hash` at insert time when known; rows
/// inserted before the hash is available stay null and the
/// reconciliation pass backfills them.
pub content_hash: Option<String>,
pub num_ctx: Option<i32>,
pub temperature: Option<f32>,
pub top_p: Option<f32>,
pub top_k: Option<i32>,
pub min_p: Option<f32>,
pub system_prompt: Option<String>,
pub persona_id: Option<String>,
pub prompt_eval_count: Option<i32>,
pub eval_count: Option<i32>,
} }
#[derive(Serialize, Queryable, Clone, Debug)] #[derive(Serialize, Queryable, Clone, Debug)]
@@ -126,6 +246,16 @@ pub struct PhotoInsight {
/// `"local"` (Ollama with images) | `"hybrid"` (local vision + OpenRouter chat). /// `"local"` (Ollama with images) | `"hybrid"` (local vision + OpenRouter chat).
pub backend: String, pub backend: String,
pub fewshot_source_ids: Option<String>, pub fewshot_source_ids: Option<String>,
pub content_hash: Option<String>,
pub num_ctx: Option<i32>,
pub temperature: Option<f32>,
pub top_p: Option<f32>,
pub top_k: Option<i32>,
pub min_p: Option<f32>,
pub system_prompt: Option<String>,
pub persona_id: Option<String>,
pub prompt_eval_count: Option<i32>,
pub eval_count: Option<i32>,
} }
// --- Libraries --- // --- Libraries ---
@@ -136,6 +266,20 @@ pub struct LibraryRow {
pub name: String, pub name: String,
pub root_path: String, pub root_path: String,
pub created_at: i64, pub created_at: i64,
/// Operator kill switch. `false` = the watcher skips this library
/// entirely (no probe, no ingest, no maintenance) and orphan-GC
/// treats it as out-of-scope for the all-online consensus rule.
/// Toggle via SQL today — there is intentionally no HTTP endpoint
/// for library mutation (see CLAUDE.md "Multi-library data model").
pub enabled: bool,
/// Per-library excluded paths/patterns, stored comma-separated
/// (same shape as the global `EXCLUDED_DIRS` env var). NULL = no
/// extra excludes for this library; the global env var still
/// applies. The runtime `Library` struct parses this into a
/// `Vec<String>` and the walker applies the union of (global,
/// library) excludes when scanning. Use case: mount a parent
/// directory while another library covers a child subtree.
pub excluded_dirs: Option<String>,
} }
#[derive(Insertable)] #[derive(Insertable)]
@@ -144,6 +288,8 @@ pub struct InsertLibrary<'a> {
pub name: &'a str, pub name: &'a str,
pub root_path: &'a str, pub root_path: &'a str,
pub created_at: i64, pub created_at: i64,
pub enabled: bool,
pub excluded_dirs: Option<&'a str>,
} }
// --- Knowledge memory models --- // --- Knowledge memory models ---
@@ -186,6 +332,44 @@ pub struct InsertEntityFact {
pub confidence: f32, pub confidence: f32,
pub status: String, pub status: String,
pub created_at: i64, pub created_at: i64,
/// Which persona authored this fact. Shared entities, persona-tagged
/// facts: each persona accumulates its own voice over the same
/// real-world referents. Defaults to `'default'` for legacy rows
/// (see migration 2026-05-09-000000).
pub persona_id: String,
/// Author's user_id. Required for the composite FK to
/// `personas(user_id, persona_id)` (migration 2026-05-10-000000) and
/// for cross-user fact isolation: two users with the same 'default'
/// persona must not see each other's facts. Always paired with
/// `persona_id` — they're a unit.
pub user_id: i32,
/// Real-world period the fact is/was true (unix seconds). NULL on
/// either side = unbounded — `valid_from IS NULL` reads as
/// "always-true-back-to-the-beginning", `valid_until IS NULL` as
/// "still-true-now-or-unknown". Distinguishes valid time from
/// transaction time (`created_at` is when we recorded the fact,
/// not when it was true in the world). See migration
/// 2026-05-10-000100.
pub valid_from: Option<i64>,
pub valid_until: Option<i64>,
/// Points at the entity_facts.id that replaced this one. Set by
/// the supersede endpoint; status flips to 'superseded' in the
/// same transaction. See migration 2026-05-10-000200.
pub superseded_by: Option<i32>,
/// Provenance for model audit — see migration 2026-05-10-000300.
/// `created_by_model` is the LLM identifier (e.g. "qwen2.5:7b",
/// "anthropic/claude-sonnet-4") or NULL for legacy / manual rows.
/// `created_by_backend` is "local" / "hybrid" / "manual" / NULL.
pub created_by_model: Option<String>,
pub created_by_backend: Option<String>,
/// Audit trail for mutations after creation — see migration
/// 2026-05-10-000500. `last_modified_*` stamp on any update
/// (status flip, valid-time edit, supersede, manual PATCH);
/// `last_modified_at` is unix seconds. NULL on rows that have
/// never been touched since creation.
pub last_modified_by_model: Option<String>,
pub last_modified_by_backend: Option<String>,
pub last_modified_at: Option<i64>,
} }
#[derive(Serialize, Queryable, Clone, Debug)] #[derive(Serialize, Queryable, Clone, Debug)]
@@ -200,6 +384,16 @@ pub struct EntityFact {
pub confidence: f32, pub confidence: f32,
pub status: String, pub status: String,
pub created_at: i64, pub created_at: i64,
pub persona_id: String,
pub user_id: i32,
pub valid_from: Option<i64>,
pub valid_until: Option<i64>,
pub superseded_by: Option<i32>,
pub created_by_model: Option<String>,
pub created_by_backend: Option<String>,
pub last_modified_by_model: Option<String>,
pub last_modified_by_backend: Option<String>,
pub last_modified_at: Option<i64>,
} }
#[derive(Insertable)] #[derive(Insertable)]
@@ -222,6 +416,45 @@ pub struct EntityPhotoLink {
pub role: String, pub role: String,
} }
// --- Personas ---
#[derive(Insertable)]
#[diesel(table_name = personas)]
pub struct InsertPersona<'a> {
pub user_id: i32,
pub persona_id: &'a str,
pub name: &'a str,
pub system_prompt: &'a str,
pub is_built_in: bool,
pub include_all_memories: bool,
pub created_at: i64,
pub updated_at: i64,
/// "Strict mode" — agent reads only see facts with status =
/// 'reviewed' (human-verified). Default false. See migration
/// 2026-05-10-000400.
pub reviewed_only_facts: bool,
/// Gate for the agent's update_fact / supersede_fact tools.
/// Default false — fresh personas let the agent create but not
/// alter or replace. Operator opts in once a model has earned
/// trust. See migration 2026-05-10-000500.
pub allow_agent_corrections: bool,
}
#[derive(Serialize, Queryable, Clone, Debug)]
pub struct Persona {
pub id: i32,
pub user_id: i32,
pub persona_id: String,
pub name: String,
pub system_prompt: String,
pub is_built_in: bool,
pub include_all_memories: bool,
pub created_at: i64,
pub updated_at: i64,
pub reviewed_only_facts: bool,
pub allow_agent_corrections: bool,
}
#[derive(Insertable)] #[derive(Insertable)]
#[diesel(table_name = video_preview_clips)] #[diesel(table_name = video_preview_clips)]
pub struct InsertVideoPreviewClip { pub struct InsertVideoPreviewClip {
@@ -246,3 +479,83 @@ pub struct VideoPreviewClip {
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
#[derive(Insertable)]
#[diesel(table_name = insight_generation_jobs)]
pub struct InsertInsightGenerationJob {
pub library_id: i32,
#[diesel(column_name = file_path)]
pub path: String,
#[diesel(column_name = generation_type)]
pub gen_type: String,
pub status: String,
pub started_at: i64,
}
#[derive(Queryable, Serialize, Clone, Debug)]
pub struct InsightGenerationJob {
pub id: i32,
pub library_id: i32,
#[diesel(column_name = file_path)]
pub path: String,
#[diesel(column_name = generation_type)]
pub gen_type: String,
pub status: String,
pub started_at: i64,
pub completed_at: Option<i64>,
pub result_insight_id: Option<i32>,
pub error_message: Option<String>,
}
// --- Precomputed reels -------------------------------------------------------
#[derive(Insertable)]
#[diesel(table_name = precomputed_reels)]
pub struct InsertablePrecomputedReel {
pub span: String,
pub library_key: String,
pub cache_key: String,
pub output_path: String,
pub title: String,
pub media_count: i32,
pub render_version: i32,
pub tz_offset_minutes: i32,
pub voice: Option<String>,
pub generated_at: i64,
}
#[derive(Serialize, Queryable, Clone, Debug)]
pub struct PrecomputedReel {
pub id: i32,
pub span: String,
pub library_key: String,
pub cache_key: String,
pub output_path: String,
pub title: String,
pub media_count: i32,
pub render_version: i32,
pub tz_offset_minutes: i32,
pub voice: Option<String>,
pub generated_at: i64,
}
// --- User AI preferences (Section E) ----------------------------------------
#[derive(Queryable, Insertable, Debug, Clone, serde::Deserialize, serde::Serialize)]
#[diesel(table_name = user_ai_prefs)]
pub struct UserAiPrefs {
pub id: i32,
pub voice: Option<String>,
pub tz_offset_minutes: Option<i32>,
pub library: Option<String>,
pub updated_at: i64,
}
#[derive(Insertable, Debug, Clone, serde::Deserialize, serde::Serialize)]
#[diesel(table_name = user_ai_prefs)]
pub struct UpsertUserAiPrefs {
pub voice: Option<String>,
pub tz_offset_minutes: Option<i32>,
pub library: Option<String>,
pub updated_at: i64,
}
+447
View File
@@ -0,0 +1,447 @@
#![allow(dead_code)]
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use std::ops::DerefMut;
use std::sync::{Arc, Mutex};
use crate::database::models::{InsertPersona, Persona};
use crate::database::schema;
use crate::database::{DbError, DbErrorKind, connect};
use crate::otel::trace_db_call;
/// Patch shape for update_persona. None = leave field alone. Built-ins are
/// allowed to flip `include_all_memories` but should reject name/prompt
/// edits at the handler layer (built-in copy lives in the migration).
pub struct PersonaPatch {
pub name: Option<String>,
pub system_prompt: Option<String>,
pub include_all_memories: Option<bool>,
pub reviewed_only_facts: Option<bool>,
pub allow_agent_corrections: Option<bool>,
}
/// One row of a bulk migration upload. Fields named to match the JSON
/// shape the mobile client uploads (`POST /personas/migrate`).
pub struct ImportPersona {
pub persona_id: String,
pub name: String,
pub system_prompt: String,
pub is_built_in: bool,
pub created_at: i64,
}
pub trait PersonaDao: Sync + Send {
fn list_personas(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
) -> Result<Vec<Persona>, DbError>;
fn get_persona(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
persona_id: &str,
) -> Result<Option<Persona>, DbError>;
fn create_persona(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
persona_id: &str,
name: &str,
system_prompt: &str,
is_built_in: bool,
include_all_memories: bool,
) -> Result<Persona, DbError>;
fn update_persona(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
persona_id: &str,
patch: PersonaPatch,
) -> Result<Option<Persona>, DbError>;
fn delete_persona(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
persona_id: &str,
) -> Result<bool, DbError>;
/// Idempotent bulk import. INSERT OR IGNORE on (user_id, persona_id)
/// — re-uploading the same set is a no-op. Returns the number of rows
/// actually inserted (skipped duplicates don't count).
fn bulk_import(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
personas: &[ImportPersona],
) -> Result<usize, DbError>;
}
pub struct SqlitePersonaDao {
connection: Arc<Mutex<SqliteConnection>>,
}
impl Default for SqlitePersonaDao {
fn default() -> Self {
Self::new()
}
}
impl SqlitePersonaDao {
pub fn new() -> Self {
Self {
connection: Arc::new(Mutex::new(connect())),
}
}
pub fn from_connection(conn: Arc<Mutex<SqliteConnection>>) -> Self {
Self { connection: conn }
}
}
impl PersonaDao for SqlitePersonaDao {
fn list_personas(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
) -> Result<Vec<Persona>, DbError> {
trace_db_call(cx, "query", "list_personas", |_span| {
use schema::personas::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
personas
.filter(user_id.eq(uid))
.order(created_at.asc())
.load::<Persona>(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn get_persona(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
pid: &str,
) -> Result<Option<Persona>, DbError> {
trace_db_call(cx, "query", "get_persona", |_span| {
use schema::personas::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
personas
.filter(user_id.eq(uid))
.filter(persona_id.eq(pid))
.first::<Persona>(conn.deref_mut())
.optional()
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn create_persona(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
pid: &str,
nm: &str,
prompt: &str,
builtin: bool,
include_all: bool,
) -> Result<Persona, DbError> {
trace_db_call(cx, "insert", "create_persona", |_span| {
use schema::personas::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
let now = chrono::Utc::now().timestamp_millis();
diesel::insert_into(personas)
.values(InsertPersona {
user_id: uid,
persona_id: pid,
name: nm,
system_prompt: prompt,
is_built_in: builtin,
include_all_memories: include_all,
created_at: now,
updated_at: now,
reviewed_only_facts: false,
allow_agent_corrections: false,
})
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))?;
personas
.filter(user_id.eq(uid))
.filter(persona_id.eq(pid))
.first::<Persona>(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
}
fn update_persona(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
pid: &str,
patch: PersonaPatch,
) -> Result<Option<Persona>, DbError> {
trace_db_call(cx, "update", "update_persona", |_span| {
use schema::personas::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
let now = chrono::Utc::now().timestamp_millis();
// Apply each field as its own UPDATE — keeps types simple
// (Diesel's tuple updates don't compose cleanly across optional
// columns) and matches the pattern already in use for entities
// (knowledge_dao.rs::update_entity).
if let Some(ref new_name) = patch.name {
diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
.set((name.eq(new_name), updated_at.eq(now)))
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Update name error: {}", e))?;
}
if let Some(ref new_prompt) = patch.system_prompt {
diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
.set((system_prompt.eq(new_prompt), updated_at.eq(now)))
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Update prompt error: {}", e))?;
}
if let Some(new_include_all) = patch.include_all_memories {
diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
.set((include_all_memories.eq(new_include_all), updated_at.eq(now)))
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Update include_all error: {}", e))?;
}
if let Some(new_reviewed_only) = patch.reviewed_only_facts {
diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
.set((
reviewed_only_facts.eq(new_reviewed_only),
updated_at.eq(now),
))
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Update reviewed_only_facts error: {}", e))?;
}
if let Some(new_allow_corrections) = patch.allow_agent_corrections {
diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
.set((
allow_agent_corrections.eq(new_allow_corrections),
updated_at.eq(now),
))
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Update allow_agent_corrections error: {}", e))?;
}
personas
.filter(user_id.eq(uid))
.filter(persona_id.eq(pid))
.first::<Persona>(conn.deref_mut())
.optional()
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
}
fn delete_persona(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
pid: &str,
) -> Result<bool, DbError> {
trace_db_call(cx, "delete", "delete_persona", |_span| {
use schema::personas::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
let n = diesel::delete(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))?;
Ok(n > 0)
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn bulk_import(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
rows: &[ImportPersona],
) -> Result<usize, DbError> {
trace_db_call(cx, "insert", "bulk_import_personas", |_span| {
let mut conn = self.connection.lock().expect("PersonaDao lock");
let now = chrono::Utc::now().timestamp_millis();
let mut inserted = 0usize;
// INSERT OR IGNORE on the (user_id, persona_id) UNIQUE so
// re-running migrate is a no-op for personas already on the
// server.
for p in rows {
let n = diesel::sql_query(
"INSERT OR IGNORE INTO personas (user_id, persona_id, name, system_prompt, \
is_built_in, include_all_memories, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, 0, ?, ?)",
)
.bind::<diesel::sql_types::Integer, _>(uid)
.bind::<diesel::sql_types::Text, _>(&p.persona_id)
.bind::<diesel::sql_types::Text, _>(&p.name)
.bind::<diesel::sql_types::Text, _>(&p.system_prompt)
.bind::<diesel::sql_types::Bool, _>(p.is_built_in)
.bind::<diesel::sql_types::BigInt, _>(p.created_at)
.bind::<diesel::sql_types::BigInt, _>(now)
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))?;
inserted += n;
}
Ok(inserted)
})
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::test::in_memory_db_connection;
fn dao_with_user(username: &str) -> (SqlitePersonaDao, i32) {
use crate::database::schema::users::dsl as u;
let conn = Arc::new(Mutex::new(in_memory_db_connection()));
diesel::insert_into(u::users)
.values((u::username.eq(username), u::password.eq("x")))
.execute(conn.lock().unwrap().deref_mut())
.unwrap();
let user_id: i32 = u::users
.filter(u::username.eq(username))
.select(u::id)
.first(conn.lock().unwrap().deref_mut())
.unwrap();
(SqlitePersonaDao::from_connection(conn), user_id)
}
#[test]
fn create_and_list_round_trip() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("alice");
// The migration seeds 3 built-ins for any existing user; alice
// was created post-migration so she starts empty.
let p = dao
.create_persona(&cx, uid, "custom-1", "Custom A", "prompt A", false, false)
.unwrap();
assert_eq!(p.persona_id, "custom-1");
assert_eq!(p.user_id, uid);
assert!(!p.is_built_in);
let list = dao.list_personas(&cx, uid).unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].persona_id, "custom-1");
}
#[test]
fn unique_constraint_blocks_duplicate_persona_id() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("bob");
dao.create_persona(&cx, uid, "x", "X", "p", false, false)
.unwrap();
let err = dao.create_persona(&cx, uid, "x", "X2", "p2", false, false);
assert!(
err.is_err(),
"second insert with same persona_id should fail"
);
}
#[test]
fn bulk_import_is_idempotent() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("carol");
let rows = vec![
ImportPersona {
persona_id: "custom-a".into(),
name: "A".into(),
system_prompt: "p1".into(),
is_built_in: false,
created_at: 1,
},
ImportPersona {
persona_id: "custom-b".into(),
name: "B".into(),
system_prompt: "p2".into(),
is_built_in: false,
created_at: 2,
},
];
let first = dao.bulk_import(&cx, uid, &rows).unwrap();
assert_eq!(first, 2);
let second = dao.bulk_import(&cx, uid, &rows).unwrap();
assert_eq!(second, 0, "re-import should insert nothing");
assert_eq!(dao.list_personas(&cx, uid).unwrap().len(), 2);
}
#[test]
fn dao_update_does_not_block_built_ins() {
// Documenting contract: the DAO is intentionally permissive —
// `update_persona` will apply name/system_prompt edits to ANY
// row, including built-ins. The guard against editing built-in
// identity (name + systemPrompt) lives in the HTTP handler
// (src/personas.rs::update_persona). If you find yourself
// wanting to add the guard here too, prefer that — defence in
// depth — but keep this test passing so anyone who removes
// the handler guard gets a failing call site, not silent data
// corruption.
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("eve");
dao.create_persona(&cx, uid, "default", "Default", "old", true, false)
.unwrap();
let updated = dao
.update_persona(
&cx,
uid,
"default",
PersonaPatch {
name: Some("Renamed".into()),
system_prompt: Some("new prompt".into()),
include_all_memories: None,
reviewed_only_facts: None,
allow_agent_corrections: None,
},
)
.unwrap()
.unwrap();
assert_eq!(updated.name, "Renamed");
assert_eq!(updated.system_prompt, "new prompt");
assert!(
updated.is_built_in,
"is_built_in flag should be unchanged by patch"
);
}
#[test]
fn update_toggles_include_all_memories() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("dan");
dao.create_persona(&cx, uid, "j", "Journal", "p", true, false)
.unwrap();
let updated = dao
.update_persona(
&cx,
uid,
"j",
PersonaPatch {
name: None,
system_prompt: None,
include_all_memories: Some(true),
reviewed_only_facts: None,
allow_agent_corrections: None,
},
)
.unwrap()
.unwrap();
assert!(updated.include_all_memories);
}
}
+439
View File
@@ -0,0 +1,439 @@
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use std::ops::DerefMut;
use std::sync::{Arc, Mutex};
use crate::database::models::{InsertablePrecomputedReel, PrecomputedReel};
use crate::database::schema;
use crate::database::{DbError, DbErrorKind, connect};
use crate::otel::trace_db_call;
/// Ledger for precomputed memory reels. The nightly agentic job writes a
/// row after each successful render; the `GET /reels/precomputed` handler
/// reads it to gate on freshness and serve the cached MP4.
pub trait PrecomputedReelDao: Sync + Send {
/// Insert a precomputed reel row. Returns the new row's id.
/// Written by the nightly agentic job (Section D).
#[allow(dead_code)]
fn record_reel(
&mut self,
context: &opentelemetry::Context,
row: &InsertablePrecomputedReel,
) -> Result<i32, DbError>;
/// Find the latest precomputed reel for the given (span, library_key).
fn latest_for(
&mut self,
context: &opentelemetry::Context,
span: &str,
library_key: &str,
) -> Result<Option<PrecomputedReel>, DbError>;
/// Return true when a fresh precomputed reel exists for the given
/// (span, library_key, render_version) that was generated at or after
/// `min_generated_at`. Used as a fast existence gate before falling
/// back to `latest_for` (avoids a second query path).
fn exists_fresh(
&mut self,
context: &opentelemetry::Context,
span: &str,
library_key: &str,
render_version: i32,
min_generated_at: i64,
) -> Result<bool, DbError>;
/// Delete all but the newest `keep` rows for (span, library_key), returning
/// the deleted rows so the caller can unlink their output files. Used by the
/// nightly job to retire superseded reels (e.g. yesterday's daily).
#[allow(dead_code)]
fn prune_superseded(
&mut self,
context: &opentelemetry::Context,
span: &str,
library_key: &str,
keep: usize,
) -> Result<Vec<PrecomputedReel>, DbError>;
/// Every cache_key currently in the ledger. Used by the on-disk cache sweep
/// to protect files a ledger row still points at.
#[allow(dead_code)]
fn all_cache_keys(&mut self, context: &opentelemetry::Context) -> Result<Vec<String>, DbError>;
}
pub struct SqlitePrecomputedReelDao {
connection: Arc<Mutex<SqliteConnection>>,
}
impl Default for SqlitePrecomputedReelDao {
fn default() -> Self {
Self::new()
}
}
impl SqlitePrecomputedReelDao {
pub fn new() -> Self {
Self {
connection: Arc::new(Mutex::new(connect())),
}
}
#[cfg(test)]
pub fn from_connection(conn: Arc<Mutex<SqliteConnection>>) -> Self {
Self { connection: conn }
}
}
impl PrecomputedReelDao for SqlitePrecomputedReelDao {
fn record_reel(
&mut self,
context: &opentelemetry::Context,
row: &InsertablePrecomputedReel,
) -> Result<i32, DbError> {
trace_db_call(context, "insert", "record_reel", |_span| {
use schema::precomputed_reels::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock PrecomputedReelDao");
diesel::insert_into(dsl::precomputed_reels)
.values(row)
.execute(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Failed to insert reel: {}", e))?;
dsl::precomputed_reels
.order(dsl::id.desc())
.select(dsl::id)
.first::<i32>(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Failed to get reel id: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
}
fn latest_for(
&mut self,
context: &opentelemetry::Context,
span: &str,
library_key: &str,
) -> Result<Option<PrecomputedReel>, DbError> {
trace_db_call(context, "query", "latest_for", |_span| {
use schema::precomputed_reels::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock PrecomputedReelDao");
dsl::precomputed_reels
.filter(dsl::span.eq(span))
.filter(dsl::library_key.eq(library_key))
.order(dsl::generated_at.desc())
.first::<PrecomputedReel>(connection.deref_mut())
.optional()
.map_err(|e| anyhow::anyhow!("Failed to get latest reel: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn exists_fresh(
&mut self,
context: &opentelemetry::Context,
span: &str,
library_key: &str,
render_version: i32,
min_generated_at: i64,
) -> Result<bool, DbError> {
trace_db_call(context, "query", "exists_fresh", |_span| {
use schema::precomputed_reels::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock PrecomputedReelDao");
let count: i64 = dsl::precomputed_reels
.filter(dsl::span.eq(span))
.filter(dsl::library_key.eq(library_key))
.filter(dsl::render_version.eq(render_version))
.filter(dsl::generated_at.ge(min_generated_at))
.count()
.get_result(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Failed to check fresh reel: {}", e))?;
Ok(count > 0)
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn prune_superseded(
&mut self,
context: &opentelemetry::Context,
span: &str,
library_key: &str,
keep: usize,
) -> Result<Vec<PrecomputedReel>, DbError> {
trace_db_call(context, "delete", "prune_superseded", |_span| {
use schema::precomputed_reels::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock PrecomputedReelDao");
// Newest first; everything past `keep` is superseded. The table
// holds at most a handful of rows per (span, library), so loading
// and slicing in Rust is cheaper than a correlated subquery.
let mut rows: Vec<PrecomputedReel> = dsl::precomputed_reels
.filter(dsl::span.eq(span))
.filter(dsl::library_key.eq(library_key))
.order(dsl::generated_at.desc())
.load::<PrecomputedReel>(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Failed to load reels for prune: {}", e))?;
let stale = rows.split_off(rows.len().min(keep));
if !stale.is_empty() {
let ids: Vec<i32> = stale.iter().map(|r| r.id).collect();
diesel::delete(dsl::precomputed_reels.filter(dsl::id.eq_any(ids)))
.execute(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Failed to delete superseded reels: {}", e))?;
}
Ok(stale)
})
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
}
fn all_cache_keys(&mut self, context: &opentelemetry::Context) -> Result<Vec<String>, DbError> {
trace_db_call(context, "query", "all_cache_keys", |_span| {
use schema::precomputed_reels::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock PrecomputedReelDao");
dsl::precomputed_reels
.select(dsl::cache_key)
.load::<String>(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Failed to load cache keys: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
}
#[cfg(test)]
mod tests {
use super::*;
use diesel::Connection;
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!();
fn setup_dao() -> SqlitePrecomputedReelDao {
let mut conn = SqliteConnection::establish(":memory:")
.expect("Unable to create in-memory db connection");
conn.run_pending_migrations(DB_MIGRATIONS)
.expect("Failure running DB migrations");
SqlitePrecomputedReelDao::from_connection(Arc::new(Mutex::new(conn)))
}
fn ctx() -> opentelemetry::Context {
opentelemetry::Context::new()
}
fn sample_row() -> InsertablePrecomputedReel {
InsertablePrecomputedReel {
span: "day".to_string(),
library_key: "1".to_string(),
cache_key: "abc123".to_string(),
output_path: "/tmp/reel.mp4".to_string(),
title: "Test Reel".to_string(),
media_count: 10,
render_version: 1,
tz_offset_minutes: 0,
voice: Some("default".to_string()),
generated_at: 1_000_000,
}
}
#[test]
fn record_reel_inserts_and_returns_id() {
let mut dao = setup_dao();
let ctx = ctx();
let row = sample_row();
let id = dao.record_reel(&ctx, &row).unwrap();
assert!(id > 0, "should return a positive id");
}
#[test]
fn record_reel_returns_increasing_ids() {
let mut dao = setup_dao();
let ctx = ctx();
let row = sample_row();
let id1 = dao.record_reel(&ctx, &row).unwrap();
let id2 = dao.record_reel(&ctx, &row).unwrap();
assert!(id2 > id1, "each insert should get a higher id");
}
#[test]
fn latest_for_returns_latest() {
let mut dao = setup_dao();
let ctx = ctx();
let row1 = InsertablePrecomputedReel {
generated_at: 1_000_000,
..sample_row()
};
let row2 = InsertablePrecomputedReel {
generated_at: 2_000_000,
..sample_row()
};
dao.record_reel(&ctx, &row1).unwrap();
dao.record_reel(&ctx, &row2).unwrap();
let latest = dao.latest_for(&ctx, "day", "1").unwrap().unwrap();
assert_eq!(latest.generated_at, 2_000_000);
}
#[test]
fn latest_for_scoped_by_span_and_library() {
let mut dao = setup_dao();
let ctx = ctx();
let day_row = InsertablePrecomputedReel {
span: "day".to_string(),
library_key: "1".to_string(),
generated_at: 1_000_000,
..sample_row()
};
let week_row = InsertablePrecomputedReel {
span: "week".to_string(),
library_key: "1".to_string(),
generated_at: 2_000_000,
..sample_row()
};
dao.record_reel(&ctx, &day_row).unwrap();
dao.record_reel(&ctx, &week_row).unwrap();
let day_latest = dao.latest_for(&ctx, "day", "1").unwrap().unwrap();
assert_eq!(day_latest.span, "day");
let week_latest = dao.latest_for(&ctx, "week", "1").unwrap().unwrap();
assert_eq!(week_latest.span, "week");
// Different library returns None
let missing = dao.latest_for(&ctx, "day", "99").unwrap();
assert!(missing.is_none());
}
#[test]
fn latest_for_returns_none_when_no_rows() {
let mut dao = setup_dao();
let ctx = ctx();
let result = dao.latest_for(&ctx, "day", "1").unwrap();
assert!(result.is_none());
}
#[test]
fn exists_fresh_returns_true_when_present() {
let mut dao = setup_dao();
let ctx = ctx();
dao.record_reel(&ctx, &sample_row()).unwrap();
let exists = dao.exists_fresh(&ctx, "day", "1", 1, 900_000).unwrap();
assert!(exists, "should find the row we just inserted");
}
#[test]
fn exists_fresh_returns_false_when_missing() {
let mut dao = setup_dao();
let ctx = ctx();
let exists = dao.exists_fresh(&ctx, "day", "1", 1, 900_000).unwrap();
assert!(!exists, "should not find anything in empty table");
}
#[test]
fn exists_fresh_respects_min_generated_at() {
let mut dao = setup_dao();
let ctx = ctx();
dao.record_reel(&ctx, &sample_row()).unwrap();
// Below the threshold — should exist
let exists = dao.exists_fresh(&ctx, "day", "1", 1, 500_000).unwrap();
assert!(exists);
// Above the threshold — should not exist
let exists = dao.exists_fresh(&ctx, "day", "1", 1, 2_000_000).unwrap();
assert!(!exists);
}
#[test]
fn exists_fresh_respects_render_version() {
let mut dao = setup_dao();
let ctx = ctx();
let row_v1 = InsertablePrecomputedReel {
render_version: 1,
..sample_row()
};
dao.record_reel(&ctx, &row_v1).unwrap();
assert!(dao.exists_fresh(&ctx, "day", "1", 1, 900_000).unwrap());
assert!(!dao.exists_fresh(&ctx, "day", "1", 2, 900_000).unwrap());
}
#[test]
fn prune_superseded_keeps_newest_and_returns_deleted() {
let mut dao = setup_dao();
let ctx = ctx();
// Three day/lib1 reels at increasing timestamps, plus an unrelated one.
for (i, key) in ["k1", "k2", "k3"].iter().enumerate() {
dao.record_reel(
&ctx,
&InsertablePrecomputedReel {
cache_key: key.to_string(),
generated_at: 1_000_000 + i as i64 * 1000,
..sample_row()
},
)
.unwrap();
}
let other = InsertablePrecomputedReel {
library_key: "2".to_string(),
cache_key: "other".to_string(),
..sample_row()
};
dao.record_reel(&ctx, &other).unwrap();
// Keep the newest 2 of (day, "1"); k1 (oldest) is superseded.
let deleted = dao.prune_superseded(&ctx, "day", "1", 2).unwrap();
assert_eq!(deleted.len(), 1);
assert_eq!(deleted[0].cache_key, "k1");
// The newest 2 survive; the other-library row is untouched.
let keys = dao.all_cache_keys(&ctx).unwrap();
assert_eq!(keys.len(), 3);
assert!(keys.contains(&"k2".to_string()));
assert!(keys.contains(&"k3".to_string()));
assert!(keys.contains(&"other".to_string()));
assert!(!keys.contains(&"k1".to_string()));
}
#[test]
fn prune_superseded_noop_when_within_keep() {
let mut dao = setup_dao();
let ctx = ctx();
dao.record_reel(&ctx, &sample_row()).unwrap();
let deleted = dao.prune_superseded(&ctx, "day", "1", 2).unwrap();
assert!(deleted.is_empty());
assert_eq!(dao.all_cache_keys(&ctx).unwrap().len(), 1);
}
}
+5 -5
View File
@@ -96,7 +96,7 @@ impl PreviewDao for SqlitePreviewDao {
.map(|_| ()) .map(|_| ())
.map_err(|e| anyhow::anyhow!("Insert error: {}", e)) .map_err(|e| anyhow::anyhow!("Insert error: {}", e))
}) })
.map_err(|_| DbError::new(DbErrorKind::InsertError)) .map_err(|e| DbError::log(DbErrorKind::InsertError, e))
} }
fn update_status( fn update_status(
@@ -126,7 +126,7 @@ impl PreviewDao for SqlitePreviewDao {
.map(|_| ()) .map(|_| ())
.map_err(|e| anyhow::anyhow!("Update error: {}", e)) .map_err(|e| anyhow::anyhow!("Update error: {}", e))
}) })
.map_err(|_| DbError::new(DbErrorKind::UpdateError)) .map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
} }
fn get_preview( fn get_preview(
@@ -148,7 +148,7 @@ impl PreviewDao for SqlitePreviewDao {
Err(e) => Err(anyhow::anyhow!("Query error: {}", e)), Err(e) => Err(anyhow::anyhow!("Query error: {}", e)),
} }
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn get_previews_batch( fn get_previews_batch(
@@ -170,7 +170,7 @@ impl PreviewDao for SqlitePreviewDao {
.load::<VideoPreviewClip>(connection.deref_mut()) .load::<VideoPreviewClip>(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Query error: {}", e)) .map_err(|e| anyhow::anyhow!("Query error: {}", e))
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn get_by_status( fn get_by_status(
@@ -188,7 +188,7 @@ impl PreviewDao for SqlitePreviewDao {
.load::<VideoPreviewClip>(connection.deref_mut()) .load::<VideoPreviewClip>(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Query error: {}", e)) .map_err(|e| anyhow::anyhow!("Query error: {}", e))
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
} }
+380
View File
@@ -0,0 +1,380 @@
//! Reconciliation pass for hash-keyed derived data.
//!
//! As `backfill_unhashed_backlog` populates `image_exif.content_hash`
//! for legacy rows, we want the matching `tagged_photo` and
//! `photo_insights` rows — which were inserted before the hash was
//! known — to inherit the hash too. Otherwise reads keep falling back
//! to the rel_path path even when a hash is now available.
//!
//! Two passes:
//! 1. **Hash backfill** — for every `tagged_photo` / `photo_insights`
//! row with NULL `content_hash`, look up the matching
//! `image_exif.content_hash` and write it. SQL-only; idempotent;
//! a no-op once everything is hashed.
//! 2. **Insight scalar merge** — when multiple `photo_insights` rows
//! share a `content_hash` with `is_current = true`, only the
//! earliest `generated_at` keeps `is_current = true` (per the
//! "earliest wins" rule in CLAUDE.md → "Multi-library data
//! model"). Others are demoted, not deleted, so they remain
//! visible in history endpoints.
//!
//! Tags are set-valued under the policy (union on read), so there's no
//! analogous "collapse" pass — duplicate `(tag_id, content_hash)` rows
//! across libraries are harmless and correctly de-duped at read time
//! by the existing `DISTINCT` queries.
//!
//! The pass operates on the database alone — no filesystem access —
//! so it doesn't need the library availability gate.
// The lib doesn't call into this module directly — the watcher (in the
// bin) does. Dead-code analysis at the lib level can't see that, so
// suppress at the module level. Tests still exercise every function.
#![allow(dead_code)]
use diesel::prelude::*;
use diesel::sql_query;
use diesel::sqlite::SqliteConnection;
use log::{debug, info, warn};
/// Outcome of a reconciliation tick. Tracked so the watcher can log
/// progress when something changed and stay quiet when nothing did.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ReconcileStats {
pub tagged_photo_hashes_filled: usize,
pub photo_insights_hashes_filled: usize,
pub photo_insights_demoted: usize,
}
impl ReconcileStats {
pub fn changed(&self) -> bool {
self.tagged_photo_hashes_filled > 0
|| self.photo_insights_hashes_filled > 0
|| self.photo_insights_demoted > 0
}
}
/// Run the reconciliation pass. Idempotent — safe to call on every
/// watcher tick. Errors are logged but never propagated; reconciliation
/// is best-effort and a transient DB hiccup must not stall the watcher.
pub fn run(conn: &mut SqliteConnection) -> ReconcileStats {
let stats = ReconcileStats {
tagged_photo_hashes_filled: match backfill_tagged_photo_hashes(conn) {
Ok(n) => n,
Err(e) => {
warn!("reconcile: tagged_photo hash backfill failed: {:?}", e);
0
}
},
photo_insights_hashes_filled: match backfill_photo_insights_hashes(conn) {
Ok(n) => n,
Err(e) => {
warn!("reconcile: photo_insights hash backfill failed: {:?}", e);
0
}
},
photo_insights_demoted: match collapse_insight_currents(conn) {
Ok(n) => n,
Err(e) => {
warn!("reconcile: photo_insights scalar merge failed: {:?}", e);
0
}
},
};
if stats.changed() {
info!(
"reconcile: filled {} tagged_photo hash(es), {} photo_insights hash(es); demoted {} non-current insight row(s)",
stats.tagged_photo_hashes_filled,
stats.photo_insights_hashes_filled,
stats.photo_insights_demoted,
);
} else {
debug!("reconcile: no changes this tick");
}
stats
}
/// Populate `tagged_photo.content_hash` for any row that still has
/// NULL by joining on `rel_path` against `image_exif`. tagged_photo
/// doesn't carry `library_id`, so a path that exists under multiple
/// libraries with different content is genuinely ambiguous; we pick
/// any non-null hash for that path. Same trade-off as the migration
/// backfill — see `migrations/2026-05-01-000000_hash_keyed_derived_data`.
fn backfill_tagged_photo_hashes(conn: &mut SqliteConnection) -> QueryResult<usize> {
sql_query(
"UPDATE tagged_photo \
SET content_hash = ( \
SELECT content_hash FROM image_exif \
WHERE image_exif.rel_path = tagged_photo.rel_path \
AND image_exif.content_hash IS NOT NULL \
LIMIT 1 \
) \
WHERE content_hash IS NULL \
AND EXISTS ( \
SELECT 1 FROM image_exif \
WHERE image_exif.rel_path = tagged_photo.rel_path \
AND image_exif.content_hash IS NOT NULL \
)",
)
.execute(conn)
}
/// Populate `photo_insights.content_hash` from `image_exif`, keyed on
/// `(library_id, rel_path)`. Unambiguous because photo_insights carries
/// library_id.
fn backfill_photo_insights_hashes(conn: &mut SqliteConnection) -> QueryResult<usize> {
sql_query(
"UPDATE photo_insights \
SET content_hash = ( \
SELECT content_hash FROM image_exif \
WHERE image_exif.library_id = photo_insights.library_id \
AND image_exif.rel_path = photo_insights.rel_path \
AND image_exif.content_hash IS NOT NULL \
LIMIT 1 \
) \
WHERE content_hash IS NULL \
AND EXISTS ( \
SELECT 1 FROM image_exif \
WHERE image_exif.library_id = photo_insights.library_id \
AND image_exif.rel_path = photo_insights.rel_path \
AND image_exif.content_hash IS NOT NULL \
)",
)
.execute(conn)
}
/// Scalar-merge step: when multiple rows share a `content_hash` and
/// claim `is_current = true`, demote all but the earliest by
/// `generated_at` (ties broken by lowest id, deterministic).
///
/// Demoted rows keep their data — only `is_current` flips. Clients that
/// hit `/insights/history` still see the full sequence; only the
/// "current" pointer is unique per hash.
fn collapse_insight_currents(conn: &mut SqliteConnection) -> QueryResult<usize> {
sql_query(
"UPDATE photo_insights \
SET is_current = 0 \
WHERE is_current = 1 \
AND content_hash IS NOT NULL \
AND id NOT IN ( \
SELECT MIN(p2.id) FROM photo_insights p2 \
WHERE p2.is_current = 1 \
AND p2.content_hash = photo_insights.content_hash \
AND p2.generated_at = ( \
SELECT MIN(p3.generated_at) FROM photo_insights p3 \
WHERE p3.is_current = 1 \
AND p3.content_hash = p2.content_hash \
) \
)",
)
.execute(conn)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::test::in_memory_db_connection;
fn ensure_library(conn: &mut SqliteConnection, library_id: i32) {
// Migration seeds library id=1; tests that reference id>1 must
// create those rows themselves, otherwise FK enforcement (added
// in the tags-edit migration) rejects image_exif inserts.
diesel::sql_query(
"INSERT OR IGNORE INTO libraries (id, name, root_path, created_at) \
VALUES (?, 'test-' || ?, '/tmp/test-' || ?, 0)",
)
.bind::<diesel::sql_types::Integer, _>(library_id)
.bind::<diesel::sql_types::Integer, _>(library_id)
.bind::<diesel::sql_types::Integer, _>(library_id)
.execute(conn)
.unwrap();
}
fn insert_image_exif(
conn: &mut SqliteConnection,
library_id: i32,
rel_path: &str,
content_hash: Option<&str>,
) {
use crate::database::schema::image_exif;
ensure_library(conn, library_id);
diesel::sql_query(
"INSERT INTO image_exif (library_id, rel_path, created_time, last_modified, content_hash) \
VALUES (?, ?, 0, 0, ?)",
)
.bind::<diesel::sql_types::Integer, _>(library_id)
.bind::<diesel::sql_types::Text, _>(rel_path)
.bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(content_hash)
.execute(conn)
.unwrap();
// Keep clippy happy that the import is used.
let _ = image_exif::table;
}
fn insert_tagged_photo(conn: &mut SqliteConnection, rel_path: &str, tag_id: i32) {
diesel::sql_query(
"INSERT INTO tagged_photo (rel_path, tag_id, created_time) VALUES (?, ?, 0)",
)
.bind::<diesel::sql_types::Text, _>(rel_path)
.bind::<diesel::sql_types::Integer, _>(tag_id)
.execute(conn)
.unwrap();
}
fn insert_tag(conn: &mut SqliteConnection, id: i32, name: &str) {
diesel::sql_query("INSERT INTO tags (id, name, created_time) VALUES (?, ?, 0)")
.bind::<diesel::sql_types::Integer, _>(id)
.bind::<diesel::sql_types::Text, _>(name)
.execute(conn)
.unwrap();
}
fn insert_insight(
conn: &mut SqliteConnection,
library_id: i32,
rel_path: &str,
generated_at: i64,
is_current: bool,
) -> i32 {
ensure_library(conn, library_id);
diesel::sql_query(
"INSERT INTO photo_insights (library_id, rel_path, title, summary, generated_at, model_version, is_current, backend) \
VALUES (?, ?, 't', 's', ?, 'v', ?, 'local')",
)
.bind::<diesel::sql_types::Integer, _>(library_id)
.bind::<diesel::sql_types::Text, _>(rel_path)
.bind::<diesel::sql_types::BigInt, _>(generated_at)
.bind::<diesel::sql_types::Bool, _>(is_current)
.execute(conn)
.unwrap();
diesel::sql_query("SELECT last_insert_rowid() AS id")
.get_result::<TestId>(conn)
.map(|r| r.id)
.unwrap()
}
#[derive(QueryableByName)]
struct TestId {
#[diesel(sql_type = diesel::sql_types::Integer)]
id: i32,
}
#[derive(QueryableByName, Debug)]
struct HashOnly {
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
content_hash: Option<String>,
}
#[derive(QueryableByName, Debug)]
struct CurrentRow {
#[diesel(sql_type = diesel::sql_types::Integer)]
id: i32,
#[diesel(sql_type = diesel::sql_types::Bool)]
is_current: bool,
}
#[test]
fn backfill_fills_tagged_photo_hash_when_image_exif_has_one() {
let mut conn = in_memory_db_connection();
insert_tag(&mut conn, 1, "vacation");
insert_tagged_photo(&mut conn, "trip/IMG.jpg", 1);
// No image_exif row yet — backfill no-op.
let stats = run(&mut conn);
assert_eq!(stats.tagged_photo_hashes_filled, 0);
// image_exif row appears with a hash; next reconcile fills it.
insert_image_exif(&mut conn, 1, "trip/IMG.jpg", Some("hashabc"));
let stats = run(&mut conn);
assert_eq!(stats.tagged_photo_hashes_filled, 1);
let row = diesel::sql_query(
"SELECT content_hash FROM tagged_photo WHERE rel_path = 'trip/IMG.jpg'",
)
.get_result::<HashOnly>(&mut conn)
.unwrap();
assert_eq!(row.content_hash.as_deref(), Some("hashabc"));
// Idempotent: a second run is a no-op.
let stats = run(&mut conn);
assert_eq!(stats.tagged_photo_hashes_filled, 0);
}
#[test]
fn backfill_skips_tagged_photo_when_image_exif_has_no_hash() {
let mut conn = in_memory_db_connection();
insert_tag(&mut conn, 1, "vacation");
insert_tagged_photo(&mut conn, "trip/IMG.jpg", 1);
// image_exif exists but its hash is null.
insert_image_exif(&mut conn, 1, "trip/IMG.jpg", None);
let stats = run(&mut conn);
assert_eq!(stats.tagged_photo_hashes_filled, 0);
}
#[test]
fn backfill_fills_photo_insights_hash_scoped_by_library() {
let mut conn = in_memory_db_connection();
// Row in library 1 only — must not be filled by a hash from
// library 2's same-rel_path entry.
insert_image_exif(&mut conn, 1, "shared.jpg", Some("hash-lib1"));
let id1 = insert_insight(&mut conn, 1, "shared.jpg", 100, true);
let stats = run(&mut conn);
assert_eq!(stats.photo_insights_hashes_filled, 1);
let row = diesel::sql_query("SELECT content_hash FROM photo_insights WHERE id = ?")
.bind::<diesel::sql_types::Integer, _>(id1)
.get_result::<HashOnly>(&mut conn)
.unwrap();
assert_eq!(row.content_hash.as_deref(), Some("hash-lib1"));
}
#[test]
fn collapse_keeps_earliest_is_current_per_hash() {
let mut conn = in_memory_db_connection();
// Two libraries, same content_hash via image_exif. Insights
// were generated independently in each library, both currently
// is_current = true. The earlier one wins.
insert_image_exif(&mut conn, 1, "a.jpg", Some("h1"));
insert_image_exif(&mut conn, 2, "a.jpg", Some("h1"));
let earlier = insert_insight(&mut conn, 1, "a.jpg", 100, true);
let later = insert_insight(&mut conn, 2, "a.jpg", 200, true);
// First pass fills the content_hash; second collapses.
let stats = run(&mut conn);
assert_eq!(stats.photo_insights_hashes_filled, 2);
assert_eq!(stats.photo_insights_demoted, 1);
let rows = diesel::sql_query("SELECT id, is_current FROM photo_insights ORDER BY id")
.get_results::<CurrentRow>(&mut conn)
.unwrap();
let earlier_row = rows.iter().find(|r| r.id == earlier).unwrap();
let later_row = rows.iter().find(|r| r.id == later).unwrap();
assert!(
earlier_row.is_current,
"earlier insight should remain current"
);
assert!(!later_row.is_current, "later insight should be demoted");
// Idempotent.
let stats = run(&mut conn);
assert_eq!(stats.photo_insights_demoted, 0);
}
#[test]
fn collapse_does_not_demote_a_solo_current_row() {
let mut conn = in_memory_db_connection();
insert_image_exif(&mut conn, 1, "a.jpg", Some("h1"));
let solo = insert_insight(&mut conn, 1, "a.jpg", 100, true);
let stats = run(&mut conn);
assert_eq!(stats.photo_insights_demoted, 0);
let row = diesel::sql_query("SELECT id, is_current FROM photo_insights WHERE id = ?")
.bind::<diesel::sql_types::Integer, _>(solo)
.get_result::<CurrentRow>(&mut conn)
.unwrap();
assert!(row.is_current);
}
}
+94
View File
@@ -57,6 +57,16 @@ diesel::table! {
confidence -> Float, confidence -> Float,
status -> Text, status -> Text,
created_at -> BigInt, created_at -> BigInt,
persona_id -> Text,
user_id -> Integer,
valid_from -> Nullable<BigInt>,
valid_until -> Nullable<BigInt>,
superseded_by -> Nullable<Integer>,
created_by_model -> Nullable<Text>,
created_by_backend -> Nullable<Text>,
last_modified_by_model -> Nullable<Text>,
last_modified_by_backend -> Nullable<Text>,
last_modified_at -> Nullable<BigInt>,
} }
} }
@@ -121,6 +131,15 @@ diesel::table! {
last_modified -> BigInt, last_modified -> BigInt,
content_hash -> Nullable<Text>, content_hash -> Nullable<Text>,
size_bytes -> Nullable<BigInt>, size_bytes -> Nullable<BigInt>,
phash_64 -> Nullable<BigInt>,
dhash_64 -> Nullable<BigInt>,
duplicate_of_hash -> Nullable<Text>,
duplicate_decided_at -> Nullable<BigInt>,
date_taken_source -> Nullable<Text>,
original_date_taken -> Nullable<BigInt>,
original_date_taken_source -> Nullable<Text>,
clip_embedding -> Nullable<Binary>,
clip_model_version -> Nullable<Text>,
} }
} }
@@ -130,6 +149,8 @@ diesel::table! {
name -> Text, name -> Text,
root_path -> Text, root_path -> Text,
created_at -> BigInt, created_at -> BigInt,
enabled -> Bool,
excluded_dirs -> Nullable<Text>,
} }
} }
@@ -150,6 +171,22 @@ diesel::table! {
} }
} }
diesel::table! {
personas (id) {
id -> Integer,
user_id -> Integer,
persona_id -> Text,
name -> Text,
system_prompt -> Text,
is_built_in -> Bool,
include_all_memories -> Bool,
created_at -> BigInt,
updated_at -> BigInt,
reviewed_only_facts -> Bool,
allow_agent_corrections -> Bool,
}
}
diesel::table! { diesel::table! {
persons (id) { persons (id) {
id -> Integer, id -> Integer,
@@ -178,6 +215,16 @@ diesel::table! {
approved -> Nullable<Bool>, approved -> Nullable<Bool>,
backend -> Text, backend -> Text,
fewshot_source_ids -> Nullable<Text>, fewshot_source_ids -> Nullable<Text>,
content_hash -> Nullable<Text>,
num_ctx -> Nullable<Integer>,
temperature -> Nullable<Float>,
top_p -> Nullable<Float>,
top_k -> Nullable<Integer>,
min_p -> Nullable<Float>,
system_prompt -> Nullable<Text>,
persona_id -> Nullable<Text>,
prompt_eval_count -> Nullable<Integer>,
eval_count -> Nullable<Integer>,
} }
} }
@@ -199,6 +246,7 @@ diesel::table! {
rel_path -> Text, rel_path -> Text,
tag_id -> Integer, tag_id -> Integer,
created_time -> BigInt, created_time -> BigInt,
content_hash -> Nullable<Text>,
} }
} }
@@ -218,6 +266,16 @@ diesel::table! {
} }
} }
diesel::table! {
user_ai_prefs (id) {
id -> Integer,
voice -> Nullable<Text>,
tz_offset_minutes -> Nullable<Integer>,
library -> Nullable<Text>,
updated_at -> BigInt,
}
}
diesel::table! { diesel::table! {
video_preview_clips (id) { video_preview_clips (id) {
id -> Integer, id -> Integer,
@@ -232,12 +290,44 @@ diesel::table! {
} }
} }
diesel::table! {
insight_generation_jobs (id) {
id -> Integer,
library_id -> Integer,
file_path -> Text,
generation_type -> Text,
status -> Text,
started_at -> BigInt,
completed_at -> Nullable<BigInt>,
result_insight_id -> Nullable<Integer>,
error_message -> Nullable<Text>,
}
}
diesel::table! {
precomputed_reels (id) {
id -> Integer,
span -> Text,
library_key -> Text,
cache_key -> Text,
output_path -> Text,
title -> Text,
media_count -> Integer,
render_version -> Integer,
tz_offset_minutes -> Integer,
voice -> Nullable<Text>,
generated_at -> BigInt,
}
}
diesel::joinable!(entity_facts -> photo_insights (source_insight_id)); diesel::joinable!(entity_facts -> photo_insights (source_insight_id));
diesel::joinable!(entity_photo_links -> entities (entity_id)); diesel::joinable!(entity_photo_links -> entities (entity_id));
diesel::joinable!(entity_photo_links -> libraries (library_id)); diesel::joinable!(entity_photo_links -> libraries (library_id));
diesel::joinable!(face_detections -> libraries (library_id)); diesel::joinable!(face_detections -> libraries (library_id));
diesel::joinable!(face_detections -> persons (person_id)); diesel::joinable!(face_detections -> persons (person_id));
diesel::joinable!(image_exif -> libraries (library_id)); diesel::joinable!(image_exif -> libraries (library_id));
diesel::joinable!(insight_generation_jobs -> libraries (library_id));
diesel::joinable!(personas -> users (user_id));
diesel::joinable!(persons -> entities (entity_id)); diesel::joinable!(persons -> entities (entity_id));
diesel::joinable!(photo_insights -> libraries (library_id)); diesel::joinable!(photo_insights -> libraries (library_id));
diesel::joinable!(tagged_photo -> tags (tag_id)); diesel::joinable!(tagged_photo -> tags (tag_id));
@@ -252,13 +342,17 @@ diesel::allow_tables_to_appear_in_same_query!(
face_detections, face_detections,
favorites, favorites,
image_exif, image_exif,
insight_generation_jobs,
libraries, libraries,
location_history, location_history,
personas,
persons, persons,
photo_insights, photo_insights,
precomputed_reels,
search_history, search_history,
tagged_photo, tagged_photo,
tags, tags,
user_ai_prefs,
users, users,
video_preview_clips, video_preview_clips,
); );
+20 -17
View File
@@ -189,10 +189,11 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
.expect("Unable to get SearchHistoryDao"); .expect("Unable to get SearchHistoryDao");
// Validate embedding dimensions (REQUIRED for searches) // Validate embedding dimensions (REQUIRED for searches)
if search.embedding.len() != 768 { if search.embedding.len() != crate::ai::embedding_dim() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Invalid embedding dimensions: {} (expected 768)", "Invalid embedding dimensions: {} (expected {})",
search.embedding.len() search.embedding.len(),
crate::ai::embedding_dim()
)); ));
} }
@@ -227,7 +228,7 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
source_file: search.source_file, source_file: search.source_file,
}) })
}) })
.map_err(|_| DbError::new(DbErrorKind::InsertError)) .map_err(|e| DbError::log(DbErrorKind::InsertError, e))
} }
fn store_searches_batch( fn store_searches_batch(
@@ -245,7 +246,7 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
conn.transaction::<_, anyhow::Error, _>(|conn| { conn.transaction::<_, anyhow::Error, _>(|conn| {
for search in searches { for search in searches {
// Validate embedding (REQUIRED) // Validate embedding (REQUIRED)
if search.embedding.len() != 768 { if search.embedding.len() != crate::ai::embedding_dim() {
log::warn!( log::warn!(
"Skipping search with invalid embedding dimensions: {}", "Skipping search with invalid embedding dimensions: {}",
search.embedding.len() search.embedding.len()
@@ -283,7 +284,7 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
Ok(inserted) Ok(inserted)
}) })
.map_err(|_| DbError::new(DbErrorKind::InsertError)) .map_err(|e| DbError::log(DbErrorKind::InsertError, e))
} }
fn find_searches_in_range( fn find_searches_in_range(
@@ -310,7 +311,7 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
.map(|rows| rows.into_iter().map(|r| r.to_search_record()).collect()) .map(|rows| rows.into_iter().map(|r| r.to_search_record()).collect())
.map_err(|e| anyhow::anyhow!("Query error: {:?}", e)) .map_err(|e| anyhow::anyhow!("Query error: {:?}", e))
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn find_similar_searches( fn find_similar_searches(
@@ -325,10 +326,11 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
.lock() .lock()
.expect("Unable to get SearchHistoryDao"); .expect("Unable to get SearchHistoryDao");
if query_embedding.len() != 768 { if query_embedding.len() != crate::ai::embedding_dim() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Invalid query embedding dimensions: {} (expected 768)", "Invalid query embedding dimensions: {} (expected {})",
query_embedding.len() query_embedding.len(),
crate::ai::embedding_dim()
)); ));
} }
@@ -372,7 +374,7 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
.map(|(_, search)| search) .map(|(_, search)| search)
.collect()) .collect())
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn find_relevant_searches_hybrid( fn find_relevant_searches_hybrid(
@@ -406,10 +408,11 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
// Step 2: If query embedding provided, rank by semantic similarity // Step 2: If query embedding provided, rank by semantic similarity
if let Some(query_emb) = query_embedding { if let Some(query_emb) = query_embedding {
if query_emb.len() != 768 { if query_emb.len() != crate::ai::embedding_dim() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Invalid query embedding dimensions: {} (expected 768)", "Invalid query embedding dimensions: {} (expected {})",
query_emb.len() query_emb.len(),
crate::ai::embedding_dim()
)); ));
} }
@@ -459,7 +462,7 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
.collect()) .collect())
} }
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn search_exists( fn search_exists(
@@ -490,7 +493,7 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
Ok(result.count > 0) Ok(result.count > 0)
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
fn get_search_count(&mut self, context: &opentelemetry::Context) -> Result<i64, DbError> { fn get_search_count(&mut self, context: &opentelemetry::Context) -> Result<i64, DbError> {
@@ -513,6 +516,6 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
Ok(result.count) Ok(result.count)
}) })
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
} }
+206
View File
@@ -0,0 +1,206 @@
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use std::ops::DerefMut;
use std::sync::{Arc, Mutex};
use crate::database::models::{UpsertUserAiPrefs, UserAiPrefs};
use crate::database::schema;
use crate::database::{DbError, DbErrorKind, connect};
use crate::otel::trace_db_call;
/// Generic single-row table that passively mirrors the latest client AI
/// request parameters (voice, timezone, library). Read by the nightly
/// pre-generation scheduler (Section D) to pick up user preferences.
pub trait UserAiPrefsDao: Sync + Send {
/// Read the single row; `None` when it hasn't been populated yet.
fn get_prefs(
&mut self,
context: &opentelemetry::Context,
) -> Result<Option<UserAiPrefs>, DbError>;
/// Upsert the single row (id is always 1).
#[allow(dead_code)]
fn upsert_prefs(
&mut self,
context: &opentelemetry::Context,
prefs: &UpsertUserAiPrefs,
) -> Result<(), DbError>;
}
pub struct SqliteUserAiPrefsDao {
connection: Arc<Mutex<SqliteConnection>>,
}
impl Default for SqliteUserAiPrefsDao {
fn default() -> Self {
Self::new()
}
}
impl SqliteUserAiPrefsDao {
pub fn new() -> Self {
Self {
connection: Arc::new(Mutex::new(connect())),
}
}
#[cfg(test)]
pub fn from_connection(conn: Arc<Mutex<SqliteConnection>>) -> Self {
Self { connection: conn }
}
}
impl UserAiPrefsDao for SqliteUserAiPrefsDao {
fn get_prefs(
&mut self,
context: &opentelemetry::Context,
) -> Result<Option<UserAiPrefs>, DbError> {
trace_db_call(context, "query", "get_prefs", |_span| {
use schema::user_ai_prefs::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock UserAiPrefsDao");
dsl::user_ai_prefs
.first::<UserAiPrefs>(connection.deref_mut())
.optional()
.map_err(|e| anyhow::anyhow!("Failed to get prefs: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn upsert_prefs(
&mut self,
context: &opentelemetry::Context,
prefs: &UpsertUserAiPrefs,
) -> Result<(), DbError> {
trace_db_call(context, "upsert", "upsert_prefs", |_span| {
use schema::user_ai_prefs::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock UserAiPrefsDao");
// Single-row table (id=1): one atomic upsert. The explicit id=1
// makes the conflict target deterministic so the second call
// updates in place rather than tripping the CHECK(id=1) constraint,
// and real insert errors surface instead of being swallowed into a
// separate update branch. The columns are set explicitly (rather
// than via AsChangeset) so a None field overwrites to NULL — the
// row mirrors the latest request exactly, not a merge of past ones.
diesel::insert_into(dsl::user_ai_prefs)
.values((dsl::id.eq(1), prefs))
.on_conflict(dsl::id)
.do_update()
.set((
dsl::voice.eq(&prefs.voice),
dsl::tz_offset_minutes.eq(&prefs.tz_offset_minutes),
dsl::library.eq(&prefs.library),
dsl::updated_at.eq(&prefs.updated_at),
))
.execute(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Failed to upsert prefs: {}", e))?;
Ok(())
})
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
}
}
#[cfg(test)]
mod tests {
use super::*;
use diesel::Connection;
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!();
fn setup_dao() -> SqliteUserAiPrefsDao {
let mut conn = SqliteConnection::establish(":memory:")
.expect("Unable to create in-memory db connection");
conn.run_pending_migrations(DB_MIGRATIONS)
.expect("Failure running DB migrations");
SqliteUserAiPrefsDao::from_connection(Arc::new(Mutex::new(conn)))
}
fn ctx() -> opentelemetry::Context {
opentelemetry::Context::new()
}
#[test]
fn get_prefs_returns_none_when_empty() {
let mut dao = setup_dao();
let result = dao.get_prefs(&ctx()).unwrap();
assert!(result.is_none());
}
#[test]
fn upsert_prefs_inserts_row() {
let mut dao = setup_dao();
let now = 1_700_000_000i64;
let prefs = UpsertUserAiPrefs {
voice: Some("grandma".to_string()),
tz_offset_minutes: Some(-480),
library: Some("1".to_string()),
updated_at: now,
};
dao.upsert_prefs(&ctx(), &prefs).unwrap();
let row = dao.get_prefs(&ctx()).unwrap().unwrap();
assert_eq!(row.id, 1);
assert_eq!(row.voice, Some("grandma".to_string()));
assert_eq!(row.tz_offset_minutes, Some(-480));
assert_eq!(row.library, Some("1".to_string()));
assert_eq!(row.updated_at, now);
}
#[test]
fn upsert_prefs_replaces_existing() {
let mut dao = setup_dao();
let now1 = 1_700_000_000i64;
let now2 = 1_800_000_000i64;
let prefs1 = UpsertUserAiPrefs {
voice: Some("grandma".to_string()),
tz_offset_minutes: Some(-480),
library: Some("1".to_string()),
updated_at: now1,
};
dao.upsert_prefs(&ctx(), &prefs1).unwrap();
let prefs2 = UpsertUserAiPrefs {
voice: Some("dad".to_string()),
tz_offset_minutes: Some(-300),
library: None,
updated_at: now2,
};
dao.upsert_prefs(&ctx(), &prefs2).unwrap();
let row = dao.get_prefs(&ctx()).unwrap().unwrap();
assert_eq!(row.voice, Some("dad".to_string()));
assert_eq!(row.tz_offset_minutes, Some(-300));
assert!(row.library.is_none());
assert_eq!(row.updated_at, now2);
}
#[test]
fn upsert_partial_fields() {
let mut dao = setup_dao();
let now = 1_700_000_000i64;
let prefs = UpsertUserAiPrefs {
voice: None,
tz_offset_minutes: Some(-480),
library: None,
updated_at: now,
};
dao.upsert_prefs(&ctx(), &prefs).unwrap();
let row = dao.get_prefs(&ctx()).unwrap().unwrap();
assert_eq!(row.tz_offset_minutes, Some(-480));
assert!(row.voice.is_none());
assert!(row.library.is_none());
}
}

Some files were not shown because too many files have changed in this diff Show More