Author SHA1 Message Date
Cameron CordesandClaude Opus 5.5 e754e149d7 parity(#143): post-migration span capture + diff vs #137 baseline
Captured from feature/tracing-migration f510940 (rebased on master 8284509),
same Jaeger / request set / DB snapshot procedure as pre-migration. Result:
names + semantic attributes preserved, traceparent intact, no orphaned spans,
async/streaming paths now single traces. See post-migration/README.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:29:31 -04:00
cameron 73523c6796 baseline(#137): pre-migration OTel span capture from da3901c
Raw Jaeger trace dumps (with and without incoming traceparent) plus a
derived span inventory and README describing the pre-migration span shape.
Captured from master da3901c release build against a local Jaeger; see
docs/tracing-baseline/pre-migration/README.md for the procedure and the
single span-neutral deviation (aux listen port 8088->18088).

Artifact branch only; /docs/* is gitignored, files force-added deliberately.
2026-09-29 13:57:05 -04:00
cameron da3901c564 Merge pull request 'Prefs: owner defaults (model, backend, persona_id, reel span/length)' (#129) from feature/prefs-owner-defaults into master
Reviewed-on: #129
2026-09-29 01:50:07 +00:00
Cameron Cordes f850c535f1 Otel: thread request context through streaming chat path (#129 review)
The legacy SSE streaming path (chat_turn_stream -> run_streaming_turn ->
run_bootstrap/continuation_streaming) built its LLM spans from
Context::current(), which is always the empty root context here: the codebase
threads OTel contexts explicitly and never attaches one, there is no
context-aware tokio layer, and the turn runs on a tokio::spawn. So every
llm.request span on that path was an orphaned root span, uncorrelated with the
HTTP request.

Thread the handler's extracted request context down through
chat_turn_stream -> chat_turn_stream_inner -> run_streaming_turn ->
run_bootstrap_streaming / run_continuation_streaming, and use it for the
describe_image and agentic-loop calls. Mirrors the non-streaming path, which
already threads parent_cx -> loop_cx.

Also rename the block-scoped db 'cx' in run_streaming_turn to db_cx so it no
longer shadows the new parameter.
2026-09-28 21:41:38 -04:00
Cameron Cordes 10ed84d7b4 Otel: dedupe LLM span error/status + traceparent helpers (#130 follow-up)
Addresses the P2 refactoring follow-ups parked on issue #130:

- otel::with_traceparent(builder, &SpanContext) — one shared W3C
  traceparent injector. Replaces the 4-line inline header loop in
  llamacpp (3x) and openrouter (3x) and removes ollama's private
  inject_traceparent method (4 call sites now use the shared helper).
  Takes &SpanContext (not &dyn Span) because the Span trait is not dyn
  compatible in opentelemetry 0.31.

- otel::record_http_failure(span, status, message) and
  otel::set_http_status(span, status) — collapse the repeated
  'set http.status_code attribute + set_status(Status::error)'
  boilerplate across all three clients (~10 sites).

Net -45 lines. The remaining if-let-Some(span.as_mut()) guards (token
attributes, parse-failure status) stay inline rather than growing a
SpanHandle wrapper type for a pattern that's already readable.
2026-09-28 10:35:28 -04:00
Cameron Cordes 696d9fa8f3 Otel: llm.request client spans + traceparent on LLM calls (#130)
Add OpenTelemetry tracing to every LLM client call so an insight/chat
turn is a single trace from HTTP entry to each downstream inference.

- LlmClient trait: thread a &opentelemetry::Context through all 5
  methods (generate, chat_with_tools, chat_with_tools_stream,
  generate_embeddings, describe_image), matching the DAO convention of
  explicit context threading rather than ambient Context::current().
- otel::start_llm_request_span: single shared helper that every
  provider (openrouter / llamacpp / ollama) funnels through, so the
  span shape is identical: name llm.request, SpanKind::Client,
  llm.provider / llm.operation / llm.model.
- Per-method spans in all three clients with the spec's operation enum
  (chat | chat_stream | embeddings | describe_image | generate); stream
  spans are owned and moved into the async_stream generator so they end
  when the response drains, not when headers arrive.
- llm.tokens.input / llm.tokens.output recorded on non-stream and
  stream completion; llm.cost_usd from usage.cost (OpenRouter);
  http.status_code + error status on failure.
- W3C traceparent injected on outgoing LLM HTTP requests (llama-swap,
  Ollama, OpenRouter) gated on span_context.is_valid() so debug
  no-op-tracer builds don't send an all-zero parent.
- Turn-level spans (ai.chat.turn.execute, ai.persona_chat.turn.execute)
  now carry llm.backend + llm.model; with_entry streaming paths thread
  the turn context (turn_cx) to child describe_image / agentic-loop
  calls instead of the ambient context.

clippy clean; 690 lib + 775 integration + 4 doc tests green.
2026-09-28 01:21:46 -04:00
Cameron Cordes e84ac82f86 ADR-0002: owner defaults live in the DB row; .env keeps deployment config and secrets
Records the config boundary the #127 owner-default columns establish:
.env = boot-time deployment config + secrets (never DB), user_ai_prefs
single row = per-request owner defaults (hot-tunable, no secrets),
resolution request param -> owner default -> hard default, and the
safe-by-default privacy posture for default_backend. Adds the
Configuration glossary section to CONTEXT.md (owner default,
deployment config, hard default).
2026-09-27 22:50:28 -04:00
Cameron Cordes a699c940b8 Prefs: owner defaults (model, backend, persona_id, reel span/length)
Closes #127.

Grows user_ai_prefs with five nullable owner-default columns and makes
them the server-side fallback layer: request param -> stored pref ->
hard default, at insight generation (agentic + standard), all three
insight-chat turn variants, and reel creation.

- PUT /prefs accepts the fields with the existing three-state partial
  update semantics (TriState generalized to TriState<T> for the numeric
  reel_max_segments); validation (canonical BackendKind::parse,
  MemoriesSpan::from_str) runs before any write.
- GET /prefs returns the new fields.
- capture_prefs reads the existing row and carries the AI owner-defaults
  through unchanged (the upsert overwrites all columns), mirroring only
  the effective reel span/segments.
- MemoriesSpan gains Display + FromStr so stored spans round-trip
  losslessly.

1463/0 tests (+27), clippy clean.
2026-09-27 19:48:45 -04:00
cameron aa3c58f051 Merge pull request 'feat(prefs): user_ai_prefs surface — GET/PUT /prefs, GET /timezones (#126)' (#128) from feature/prefs-endpoints into master
Reviewed-on: #128
2026-09-27 21:38:23 +00:00
Cameron Cordes 2258a0eab5 feat(prefs): user_ai_prefs surface — GET/PUT /prefs, GET /timezones (#126)
- GET /prefs (JWT): current row or empty {voice, library, display_tz,
  updated_at} shape — snake_case, 200 with nulls when no row
- PUT /prefs (JWT): three-state partial update (omitted=unchanged,
  explicit null=clear, value=set) via hand-rolled TriState deserializer —
  serde derive conflates omitted with explicit null on Option<Option<T>>
- display_tz validated against chrono-tz before any write; invalid → 400,
  nothing written. tz_offset_minutes (frozen legacy) preserved verbatim
- GET /timezones (public): chrono_tz::TZ_VARIANTS — exactly the domain
  Tz::from_str accepts, so picker list and validation can't drift
- No migration; existing columns only. capture_prefs (POST /reels) untouched
- 14 tests: 3 GET /prefs, 7 PUT /prefs (incl. auth-gate), 2 /timezones,
  2 pure merge_patch. Full suite 1436 passed / 0 failed
2026-09-27 14:40:30 -04:00
cameron 7f348022d4 Merge pull request 'feat(dates): read paths on the true-instant contract — /memories, reels, insights (#119)' (#125) from feature/date-reads-insights into master
Reviewed-on: #125
2026-09-27 15:07:12 +00:00
Cameron Cordes 615941a511 fix(dates): assumed_server fallback uses the offset at the photo's wall, not now's
A container whose /etc/localtime is a copy rather than a symlink makes
server_zone() return None; the assumed_server rung then fell back to
Local::now()'s offset, which is only correct for captures from the
current season. Historical winter walls on a DST host skewed date_utc
by an hour. Resolve the process-local zone at the wall instead
(earliest reading for ambiguous walls).

Surfaced by test-server validation of #119.
2026-09-26 23:38:50 -04:00
Cameron Cordes af87765bbb Switch read paths to the true-instant date contract (ticket #119)
- New src/date_view.rs: pure read-side functions (row_true_instant,
  row_display_wall, row_display_day, filename_true_instant) applying the
  per-source convention table for unconverted rows and the new-contract
  triple for converted rows (ADR-0001).
- /memories: grouping on the row's true instant rendered in the owner's
  display zone (prefs -> app default), not the client device offset.
  SQL is a coarse interval pre-filter over the full years-back window;
  exact month-day/week/month matching across past years happens in Rust
  (memories::day_in_span, sqlite_w_week).
- Reels: ReelSelector::Memories carries an IANA display_tz instead of a
  fixed minute offset; pre-gen params read REEL_PREGEN_DISPLAY_TZ; the
  tz_offset_minutes ledger column stores the zone offset at render time.
- Insights: prompt-facing dates render as the display wall; the
  proximity timestamp and chat context block use the true instant;
  InsightGenerator reads the owner's display zone from prefs per call.
- GpsPhotoSummary carries the new-contract triple alongside the frozen
  legacy date_taken.

Closes #119
2026-09-26 19:28:26 -04:00
cameron cd375f18db Merge pull request 'feat(dates): backfill drain converts mixed-convention rows to true instants (#118)' (#124) from feature/date-backfill-drain into master
Reviewed-on: #124
2026-09-26 14:42:11 +00:00
Cameron CordesandClaude Opus 5.5 fcaf8487c2 fix(dates): resume cursor so unconvertible rows can't stall the #118 drain
Review follow-up: rows that exist but can't be faithfully resolved spent
the cap and were re-collected first every tick. Carry a per-library
cursor across ticks (wrapping at end of table), share the cap parser
between the two date drains, and name the conversion tuple.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:36:50 -04:00
Cameron CordesandClaude Opus 5.5 98db2a48db feat(dates): backfill drain converts mixed-convention rows to true instants (#118)
Per-tick drain (backfill_utc_dates) at the existing date-drain seam,
inside the per-library online gate. Selects date_utc IS NULL AND
date_taken IS NOT NULL (new partial index idx_image_exif_utc_backfill),
re-runs resolve_dates_batch with empty priors (one exiftool subprocess
per batch, same call shape as the #117 audit), and writes only the
new-contract triple; the legacy pair stays frozen. Capped by
DATE_BACKFILL_MAX_PER_TICK; missing-file rows are paged past without
spending the cap. Legacy manual overrides convert via
manual_date_resolution rather than being re-resolved from the file.

Day-boundary invalidation deletes the day-span precomputed reels
covering a re-dated row's old/new display-zone month-day so the nightly
pre-gen regenerates them. Progress: imageserver_date_backfill_remaining
gauge + date_backfill_remaining on GET /libraries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:36:50 -04:00
cameron ab16bc7da6 Merge pull request 'feat(dates): pre-cutover audit utility for the filename date class (#117)' (#123) from feature/filename-audit into master
Reviewed-on: #123
2026-09-26 14:32:12 +00:00
Cameron Cordes 89fc24ceab feat(dates): print the numeric offset in the audit report
The spec-mirroring review noted the report showed the offset SOURCE
but not the offset value — the value is what the operator spot-checks.
Each proposed line now ends with offset={+N}m (or - for utc_direct
rows). Tests pin the rendered values.
2026-09-25 18:14:02 -04:00
Cameron Cordes 3aa6ce314c feat(dates): pre-cutover audit utility for the filename date class (#117)
One-shot, operator-run, strictly read-only utility that lists every
filename-class file with its stored date/source and the date/source/
offset-source the re-based resolver would assign it — the human
spot-check that gates the production backfill drain (#118).

- src/filename_audit.rs: StoredDate/AuditRow/SkippedLibrary; walk +
  PathExcluder (depth-0 guarded) + match_filename_date -> one
  resolve_dates_batch pass with EMPTY priors (mirrors the drain
  exactly); disabled and probe-stale libraries reported as skipped,
  never partially processed; output stable-sorted by (library, path).
- src/bin/filename_audit.rs: operator binary (dotenv, DATABASE_URL,
  EXCLUDED_DIRS, libraries::load_all, format_report).
- src/memories.rs: match_filename_date canonical matcher returning
  FilenameMatch { pattern, date }; extract_date_from_filename is now
  a thin wrapper (byte-for-byte behavior-preserving, verified by
  review).

4 audit tests (inversion + utc_direct + exclusions, offline/disabled
skip, stable sort, report determinism incl. no debug quotes) + 2
matcher tests. 645 lib + 718 integration pass; clippy/fmt clean.
Standards review applied: {:?}->{}, depth-0 walk guard, dead
per-file exclude check removed, unused Default derive removed,
pattern column widened, doc comment reworded.
2026-09-25 18:03:38 -04:00
51 changed files with 6852 additions and 562 deletions

No files matched your search

+5
View File
@@ -533,6 +533,11 @@ POST /insights/chat (single-turn reply, non-streaming)
POST /insights/chat/stream (SSE: text / tool_call / tool_result / truncated / done)
GET /insights/chat/history?path=... (rendered transcript with tool invocations)
POST /insights/chat/rewind (truncate transcript at a rendered index)
// User AI Prefs (owner defaults + passive mirror; src/prefs.rs)
GET /prefs (current row; auth)
PUT /prefs (partial update: omitted=unchanged, null=clear, value=set; auth)
GET /timezones (IANA zone list from chrono-tz; unauthenticated)
```
**Request Types:**
+22
View File
@@ -62,3 +62,25 @@ Insights and pre-computed artifacts grouped by calendar day (daily message summa
reel pre-generation). Because it is keyed on the day, re-dating a photo across a day
boundary invalidates the derived data for that day, which regenerates on its normal schedule.
_Avoid_: cache, nightly output
### Configuration
**Owner default**:
A per-request default the owner sets for the whole deployment — the model, backend,
persona, reel span, or segment cap a client gets when it omits the field. Stored in the
single `user_ai_prefs` row, hot-tunable without a redeploy, and global: every client that
omits the field gets it, on every device. Never holds secrets.
_Avoid_: user prefs, user settings, preferences (ambiguous with device-local client state)
**Deployment config**:
Values the server needs before it can start serving — bind address, database path, media
roots, the URLs used to build clients — plus every secret. Lives in `.env` only; changing
it is a redeploy, by design.
_Avoid_: environment config, system config
**Hard default**:
The env/code fallback a request lands on when neither an explicit param nor a stored
owner default supplies the field (e.g. backend `local`, persona `default`, reel span
day). It is the floor under the owner-default layer: env changes keep working whenever a
default column is NULL.
_Avoid_: fallback value, env default
+78
View File
@@ -0,0 +1,78 @@
# ADR-0002: Owner defaults live in the DB row; .env keeps deployment config and secrets
**Status**: accepted (2026-09-27)
## Context
All configuration lived in `.env`: bind address, media paths, Ollama/llama-swap/OpenRouter
URLs, model names, `LLM_BACKEND`, the embedding contract, TTS voices. Two friction points:
values the owner actually *tunes* (which model, which backend, which persona, how long a
reel is) needed an edit + redeploy to change, and clients had no server-side fallback —
"use the default" was expressed by omitting a field, and the default itself was invisible
and untouchable at runtime. The `user_ai_prefs` single row (`id = 1`) already existed as
a passive mirror of the last client reel request for the nightly pre-gen. #126/#127 gave
it an explicit `PUT /prefs` surface and five owner-default columns (`default_model`,
`default_backend`, `default_persona_id`, `reel_span`, `reel_max_segments`) — the first
batch of non-private configuration moved out of `.env` and into the database.
## Decision
Configuration splits into two homes by one rule — *does the server need the value before
it can start serving?*
- **`.env` (deployment config)**: everything read at boot to bind, connect, or open —
`BIND_URL`, `DATABASE_URL`, media paths, `OLLAMA_*_URL` / `LLAMA_SWAP_URL`,
`LLM_BACKEND`, the embedding contract — and **all secrets** (`OPENROUTER_API_KEY`,
`SECRET_KEY`, `SMS_API_TOKEN`). Secrets never go in the database: the SQLite file gets
backed up, copied, and attached to bug reports.
- **`user_ai_prefs` (owner defaults)**: per-request defaults resolved at request time —
the model/backend/persona a client gets when it omits the field, reel span and
segment cap, display zone, TTS voice. Hot-tunable via `PUT /prefs`, effective on the
next request, no redeploy. This row is the designated home for future non-private,
per-request configuration that outgrows `.env`. The table is a **single row per
deployment, not a per-user profile** — `user_ai_prefs` is a historical name; the
canonical term is *owner defaults* (single-user tool: owner = admin).
Resolution order at every consumer: **explicit request param → stored owner default →
hard default** (the env/code fallback). An explicit request always wins, so any client
can override the owner's default per request. The env value stays the floor under the
DB: a NULL `default_model` means "whatever the Ollama/llama-swap primary is", so env
changes keep working.
**Privacy posture is safe-by-default.** `default_backend` is the one owner default with
network-egress consequences: `hybrid` routes insight generation and chat to OpenRouter.
With no row or a NULL column, the hard default is `local` — data stays on the LAN.
Leaving the network requires an explicit owner action (storing `hybrid`), and any client
can still pin itself by sending `backend` explicitly. Owner defaults therefore split
into two sensitivity classes: **harmless tuning** (span, segments, display zone, voice,
model, persona) and **privacy/security** (`default_backend`), which any admin UI must
surface with a warning, not a quiet dropdown.
## Considered options
- **Everything in `.env`** (status quo): safe, but tuning a model default means edit +
redeploy, and clients have no server-side default to fall back to — a picker's
"server default" is just the env value, invisible and unchangeable at runtime.
Rejected for per-request defaults.
- **Everything in the DB**: boot-time values (bind address, DB path, the URLs used to
build the HTTP clients) become chicken-and-egg, and secrets sit next to tuning values
in a file that gets copied around. Rejected.
- **A separate `system_config` table**: cleaner name, but a second single-row table
would be a synonym with extra migrations; the row and the `PUT /prefs` surface already
exist. Rejected — the row keeps its name, its *term* becomes owner defaults.
## Non-obvious rules worth remembering
- **Owner defaults are global to the deployment.** Every client that omits a field gets
the stored value, on every device. Storing `default_backend=hybrid` changes the
mobile app's behavior even for users who never touch the setting; a client that must
stay local should send `backend` explicitly (tracked for FileViewer-React #33).
- **A stored value that is no longer consumable fails at write time, not request time** —
`PUT /prefs` validates `default_backend` against the canonical `BackendKind::parse`
and `reel_span` against the span enum before writing, so a bad default cannot 500
every request later.
- **The passive reel mirror (`capture_prefs`) may only write the fields it owns** (voice,
library, display zone, effective span/segments); it carries the AI owner-defaults
through unchanged, because the upsert overwrites every column.
- **NULL means "use the hard default", not "unset the env"** — the DB layer sits
*above* env, never instead of it.
@@ -0,0 +1,81 @@
# Post-migration span capture + parity diff (ticket #143, map #135)
The "after" counterpart of `../pre-migration/` (#137). It is captured the same
way, against the same local Jaeger, with the same request set, and diffed
against `../pre-migration/span-inventory.json`.
## What was run
- **Revision:** `feature/tracing-migration` @ `f510940`, rebased onto master
`8284509` (dev/e2e server). Release build. No source patch was needed:
master's `LOCAL_BIND_URL=` disables the secondary `:8088` listener.
- **Exporter:** the same local Jaeger all-in-one (OTLP/gRPC `:14317`). Traces
were filtered by capture start time, because the container still holds the
baseline traces.
- **DB:** a fresh `.backup` of the live `auth.db` (21,298 `image_exif` rows,
4 libraries, max job id 379, so the agentic job is again **380**). Output
dirs point at temp. `REEL_PREGEN_ENABLED=0`, `REEL_CACHE_SWEEP_ENABLED=0`.
- **LLM:** the live local stack (`LLM_BACKEND=llamacpp`,
`qwen3.8-27b-abliterated`).
- **Requests:** the baseline table, replayed by `replay.sh`: photos, memories,
agentic (202, polled to `completed`), chat stream (87 deltas + `done`), and
the `traceparent: 00-1111…-2222…-01` probe.
Two captures were taken. Run 1 found two gaps, which are fixed in `f510940`
(see "Gaps found and fixed"). Run 2, the files here, is the capture of record.
## Parity result
| Check | Before (#137) | After | Verdict |
|---|---|---|---|
| Operation names | — | every baseline op present (`find_relevant_events_hybrid` is a model-chosen tool; run 1 hit it, run 2 didn't) | ✅ preserved |
| Attribute keys (semantic) | per `pre-migration/span-inventory.json` | **identical** for every common op (`list_photos`' ~27 request attrs, `ai.insight.generate_agentic`, `ai.rag.*`, `generate_video_thumbnail`, `db.*`, `llm.request`) | ✅ preserved |
| `llm.request` | kind=client; `llm.model` / `llm.provider` / `llm.operation` / `llm.tokens.*` / `http.status_code` | same, on all 12 LLM spans | ✅ |
| `llm.backend` | absent from every baseline span (it lives on the async-turn path, which neither capture exercises) | same | ✅ no loss |
| Incoming `traceparent` | `list_photos` CHILD_OF remote `2222…` in trace `1111…` | the `GET /photos` root span is CHILD_OF remote `2222…`, with `list_photos` + DB spans under it, all in trace `1111…` | ✅ intact |
| DB span nesting | most DB spans were single-span traces | **no DB span is a root**; each nests under its request / feature / job span | ✅ fixed |
| Agentic 202 → background | two traces (`948d2756…` / `9b520713…`) | one 52-span trace: `POST …/agentic` → `http.insights.generate_agentic` → `ai.insight.generate_agentic` → `ai.agentic.loop` → `llm.request` / `ai.rag.*` / DB | ✅ fixed |
| Streaming chat | fragmented, no handler span | one trace: `POST /insights/chat/stream` → `ai.chat.turn.stream` → `llm.request (chat_stream)` + DB | ✅ fixed |
| Thumbnail sweep | 222 `generate_video_thumbnail` in 1 trace | 414 in 1 trace, all with one parent (the `creating thumbnails` root, still open when capture stopped) | ✅ |
**Roots in the after capture:** only request roots (`GET /photos`,
`GET /memories`, `POST …/agentic`, `POST /insights/chat/stream`,
`GET /insights/generation/status`) and the background root
`watcher.initial_probe`. **No orphans.**
## Intentional changes (vs baseline)
- **New per-request root spans** (`GET /photos`, …) from `tracing-actix-web`
(#139). Operation spans (`list_photos`, …) now sit one level under them.
- **New spans:** `ai.chat.turn.stream` (#140), `watcher.initial_probe` and the
other job roots (#142); `db.query.get_job_by_id` shows up because the
replay now polls job status.
- **DB tracer scope:** `db` → `image-server`. DB spans come from the
subscriber's tracer (#141). The span names and `db.*` attributes are unchanged.
- **Extra per-span metadata** from tracing-opentelemetry, excluded from the key
diff above: `code.file.path` / `code.line.number` / `code.module.name`,
`thread.id` / `thread.name`, `target`, `busy_ns` / `idle_ns`.
- **Removed:** the zero-length `file write` span in `upload_image` (#141). It
wasn't exercised by either capture.
## Gaps found and fixed (run 1 → `f510940`)
1. **Agentic background still split off.** Both insight handlers run the
generation in a nested `tokio::task::spawn` (for panic isolation) that
wasn't instrumented, so `ai.insight.generate_agentic` was a root.
2. **Watcher boot probe.** `count_for_library` ran before the first tick span,
leaving 4 root DB spans. It now runs under `watcher.initial_probe`.
## Files
- `traces.json` — Jaeger `/api/traces` dump for run 2.
- `span-inventory.json` — the baseline's schema, plus `parents` / `root_count`.
- `diff-report.txt` — the full `analyze.py` output (before/after diff, roots,
ancestry chains, traceparent chain, per-`llm.request` attributes).
- `analyze.py`, `replay.sh` — reproduce with
`python3 analyze.py <dir-with traces-after.json + inventory-before.json>`.
## Full suite on `f510940`
`cargo build` ✅, `cargo clippy --all-targets` 0 warnings ✅, `cargo fmt --check` ✅,
`cargo test` ✅ (690 lib + 775 integration + 5 OTel regression + 4 doc).
@@ -0,0 +1,149 @@
"""#143 parity analysis: after-capture Jaeger dump vs the #137 baseline.
Writes span-inventory.json (baseline schema + nesting columns) and prints a
before/after diff plus targeted nesting / propagation checks.
"""
import json
import sys
from collections import defaultdict
W = sys.argv[1]
after = json.load(open(f"{W}/traces-after.json"))["data"]
before_inv = {r["operation"]: r for r in json.load(open(f"{W}/inventory-before.json"))}
# Attributes tracing-opentelemetry adds to every span (location / thread /
# timing metadata). Reported separately so the diff shows semantic changes.
TRACING_EXTRAS = {
"busy_ns", "idle_ns", "target", "thread.id", "thread.name",
"code.file.path", "code.line.number", "code.module.name",
}
# Spans produced by my auth probes, not by the baseline request set.
PROBE_NOISE = set()
spans = {}
by_trace = defaultdict(list)
for t in after:
for s in t["spans"]:
s["_tags"] = {kv["key"]: kv["value"] for kv in s["tags"]}
spans[s["spanID"]] = s
by_trace[s["traceID"]].append(s)
def parent(s):
for r in s.get("references", []):
if r["refType"] == "CHILD_OF":
return r["spanID"], r["traceID"]
return None, None
def op(sid):
return spans[sid]["operationName"] if sid in spans else None
# Inventory in the baseline's schema, plus nesting columns.
inv = defaultdict(lambda: {"span_count": 0, "traces": set(), "kinds": set(), "scopes": set(),
"statuses": set(), "keys": set(), "parents": defaultdict(int),
"orphans": 0})
for s in spans.values():
r = inv[s["operationName"]]
r["span_count"] += 1
r["traces"].add(s["traceID"])
r["kinds"].add(s["_tags"].get("span.kind", "internal"))
r["scopes"].add(s["_tags"].get("otel.scope.name", "?"))
r["statuses"].add(s["_tags"].get("otel.status_code", "(none)"))
r["keys"].update(s["_tags"])
pid, _ = parent(s)
if pid is None:
r["orphans"] += 1
else:
r["parents"][op(pid) or "(remote)"] += 1
inventory = []
for name, r in sorted(inv.items(), key=lambda kv: -kv[1]["span_count"]):
inventory.append({
"operation": name,
"span_count": r["span_count"],
"trace_count": len(r["traces"]),
"kind": "/".join(sorted(r["kinds"])),
"scope": "/".join(sorted(r["scopes"])),
"status": "/".join(sorted(r["statuses"])),
"attribute_keys": sorted(r["keys"] - TRACING_EXTRAS),
"parents": dict(r["parents"]),
"root_count": r["orphans"],
})
json.dump(inventory, open(f"{W}/span-inventory.json", "w"), indent=2)
print("== Operations: before -> after ==")
before_ops, after_ops = set(before_inv), set(inv)
print("only before:", sorted(before_ops - after_ops))
print("only after: ", sorted(after_ops - before_ops))
print()
print("== Common operations: kind / scope / attribute-key changes ==")
for name in sorted(before_ops & after_ops):
b, a = before_inv[name], next(x for x in inventory if x["operation"] == name)
bk, ak = set(b["attribute_keys"]), set(a["attribute_keys"])
changes = []
if b["kind"] != a["kind"]:
changes.append(f"kind {b['kind']}->{a['kind']}")
if b["scope"] != a["scope"]:
changes.append(f"scope {b['scope']}->{a['scope']}")
if bk - ak:
changes.append(f"LOST {sorted(bk - ak)}")
if ak - bk:
changes.append(f"added {sorted(ak - bk)}")
nest = f"traces {b['trace_count']}/{b['span_count']} -> {a['trace_count']}/{a['span_count']}, roots={a['root_count']}"
print(f"{name}: {'; '.join(changes) or 'same'} | {nest}")
print()
print("== Roots in the after capture (span with no parent) ==")
for s in spans.values():
if parent(s)[0] is None:
print(f" {s['operationName']} trace={s['traceID'][:8]} spans_in_trace={len(by_trace[s['traceID']])}")
def chain(s):
out = [s["operationName"]]
pid, _ = parent(s)
while pid:
if pid not in spans:
out.append(f"(remote {pid})")
break
out.append(spans[pid]["operationName"])
pid, _ = parent(spans[pid])
return " <- ".join(out)
print()
print("== Ancestry of key spans ==")
seen = set()
for s in spans.values():
n = s["operationName"]
key = (n, s["_tags"].get("llm.operation"))
if n in ("llm.request", "ai.agentic.loop", "ai.insight.generate_agentic",
"db.insert.store_insight", "db.update.update_training_messages",
"ai.chat.turn.stream", "list_photos", "list_memories",
"generate_video_thumbnail") and key not in seen:
seen.add(key)
extra = f" [llm.operation={key[1]}]" if key[1] else ""
print(f" {chain(s)}{extra}")
print()
print("== traceparent probe ==")
probe = [s for s in spans.values() if s["traceID"].startswith("11111111")]
for s in sorted(probe, key=lambda s: s["startTime"]):
print(f" {chain(s)} trace={s['traceID']}")
print()
print("== llm.request attribute presence ==")
for s in spans.values():
if s["operationName"] == "llm.request":
t = s["_tags"]
print(f" op={t.get('llm.operation')} model={t.get('llm.model')} provider={t.get('llm.provider')} "
f"status={t.get('http.status_code')} tokens_in={t.get('llm.tokens.input')} "
f"parent={op(parent(s)[0])}")
print()
print("== turn / insight spans: llm.backend + llm.model ==")
for s in spans.values():
if s["operationName"] in ("ai.chat.turn.stream", "ai.insight.generate_agentic"):
t = s["_tags"]
print(f" {s['operationName']}: llm.backend={t.get('llm.backend')} llm.model={t.get('llm.model')} backend={t.get('backend')} model={t.get('model')}")
@@ -0,0 +1,108 @@
== Operations: before -> after ==
only before: ['db.query.find_relevant_events_hybrid']
only after: ['GET /insights/generation/status', 'GET /memories', 'GET /photos', 'POST /insights/chat/stream', 'POST /insights/generate/agentic', 'ai.chat.turn.stream', 'db.query.get_job_by_id', 'watcher.initial_probe']
== Common operations: kind / scope / attribute-key changes ==
ai.agentic.loop: same | traces 1/1 -> 1/1, roots=0
ai.insight.generate_agentic: same | traces 1/1 -> 1/1, roots=0
ai.rag.search_daily_summaries: same | traces 1/1 -> 1/2, roots=0
db.insert.create_job: scope db->image-server | traces 1/1 -> 1/1, roots=0
db.insert.store_insight: scope db->image-server | traces 1/1 -> 1/1, roots=0
db.insert.upsert_entity: scope db->image-server | traces 1/2 -> 1/3, roots=0
db.insert.upsert_fact: scope db->image-server | traces 1/1 -> 1/2, roots=0
db.insert.upsert_photo_link: scope db->image-server | traces 1/1 -> 1/2, roots=0
db.query.count_for_library: scope db->image-server | traces 4/4 -> 1/4, roots=0
db.query.faces_for_hash: scope db->image-server | traces 1/1 -> 1/1, roots=0
db.query.find_similar_summaries_with_time_weight: scope db->image-server | traces 1/1 -> 1/2, roots=0
db.query.get_active_job: scope db->image-server | traces 1/1 -> 1/1, roots=0
db.query.get_current_insight_for_library: scope db->image-server | traces 1/1 -> 1/1, roots=0
db.query.get_event_count: scope db->image-server | traces 2/2 -> 2/2, roots=0
db.query.get_exif: scope db->image-server | traces 2/2 -> 1/3, roots=0
db.query.get_links_for_photo: scope db->image-server | traces 1/1 -> 1/1, roots=0
db.query.get_location_count: scope db->image-server | traces 2/2 -> 2/2, roots=0
db.query.get_memories_in_window: scope db->image-server | traces 1/4 -> 1/4, roots=0
db.query.get_persona: scope db->image-server | traces 4/4 -> 2/4, roots=0
db.query.get_prefs: scope db->image-server | traces 4/4 -> 3/4, roots=0
db.query.get_tag_counts_batch: scope db->db/image-server | traces 1/1 -> 2/4, roots=0
db.query.get_tags_for_path: scope db->image-server | traces 1/1 -> 1/1, roots=0
db.query.has_any_faces: scope db->image-server | traces 2/2 -> 2/2, roots=0
db.query.has_any_summaries: scope db->image-server | traces 2/2 -> 2/2, roots=0
db.query.list_entities: scope db->image-server | traces 1/2 -> 1/4, roots=0
db.query.list_rel_paths_for_libraries: scope db->db/image-server | traces 1/1 -> 2/4, roots=0
db.query.resolve_content_hash: scope db->image-server | traces 1/1 -> 1/1, roots=0
db.update.cancel_active_jobs: scope db->image-server | traces 1/1 -> 1/1, roots=0
db.update.complete_job: scope db->image-server | traces 1/1 -> 1/1, roots=0
db.update.update_facts_insight_id: scope db->image-server | traces 1/1 -> 1/1, roots=0
db.update.update_training_messages: scope db->image-server | traces 1/1 -> 1/1, roots=0
generate_video_thumbnail: same | traces 1/222 -> 1/414, roots=0
http.insights.generate_agentic: same | traces 1/1 -> 1/1, roots=0
list_memories: same | traces 1/1 -> 1/1, roots=0
list_photos: same | traces 1/1 -> 2/4, roots=0
llm.request: same | traces 3/8 -> 2/12, roots=0
== Roots in the after capture (span with no parent) ==
GET /insights/generation/status trace=4e4c40ef spans_in_trace=2
GET /insights/generation/status trace=9b6f5cad spans_in_trace=2
GET /insights/generation/status trace=92fe175c spans_in_trace=2
GET /insights/generation/status trace=a1c2c449 spans_in_trace=2
GET /insights/generation/status trace=73ad5d54 spans_in_trace=2
GET /insights/generation/status trace=96ba44fa spans_in_trace=2
GET /insights/generation/status trace=63be2acf spans_in_trace=2
POST /insights/chat/stream trace=119b1ad6 spans_in_trace=11
GET /insights/generation/status trace=d0f13d49 spans_in_trace=2
GET /insights/generation/status trace=01e0824c spans_in_trace=2
watcher.initial_probe trace=09884c56 spans_in_trace=5
GET /insights/generation/status trace=4a47ba01 spans_in_trace=2
POST /insights/generate/agentic trace=881c56c0 spans_in_trace=52
GET /insights/generation/status trace=87857a0b spans_in_trace=2
GET /insights/generation/status trace=e1cf97cb spans_in_trace=2
GET /insights/generation/status trace=5f02a109 spans_in_trace=2
GET /insights/generation/status trace=6fb52a67 spans_in_trace=2
GET /insights/generation/status trace=2512bbae spans_in_trace=2
GET /photos trace=4cd7207a spans_in_trace=4
GET /memories trace=0c26f842 spans_in_trace=7
GET /insights/generation/status trace=6844193f spans_in_trace=2
== Ancestry of key spans ==
list_photos <- (remote 2222222222222222)
llm.request <- ai.chat.turn.stream <- POST /insights/chat/stream [llm.operation=chat_stream]
db.update.update_training_messages <- ai.chat.turn.stream <- POST /insights/chat/stream
ai.chat.turn.stream <- POST /insights/chat/stream
llm.request <- ai.rag.search_daily_summaries <- ai.agentic.loop <- ai.insight.generate_agentic <- http.insights.generate_agentic <- POST /insights/generate/agentic [llm.operation=embeddings]
llm.request <- ai.agentic.loop <- ai.insight.generate_agentic <- http.insights.generate_agentic <- POST /insights/generate/agentic [llm.operation=chat]
ai.agentic.loop <- ai.insight.generate_agentic <- http.insights.generate_agentic <- POST /insights/generate/agentic
db.insert.store_insight <- ai.insight.generate_agentic <- http.insights.generate_agentic <- POST /insights/generate/agentic
ai.insight.generate_agentic <- http.insights.generate_agentic <- POST /insights/generate/agentic
list_memories <- GET /memories
generate_video_thumbnail <- (remote 4b78d96000f31655)
== traceparent probe ==
list_photos <- (remote 2222222222222222) trace=11111111111111111111111111111111
db.query.list_rel_paths_for_libraries <- list_photos <- (remote 2222222222222222) trace=11111111111111111111111111111111
db.query.get_tag_counts_batch <- list_photos <- (remote 2222222222222222) trace=11111111111111111111111111111111
GET /photos <- (remote 2222222222222222) trace=11111111111111111111111111111111
list_photos <- GET /photos <- (remote 2222222222222222) trace=11111111111111111111111111111111
db.query.list_rel_paths_for_libraries <- list_photos <- GET /photos <- (remote 2222222222222222) trace=11111111111111111111111111111111
db.query.get_tag_counts_batch <- list_photos <- GET /photos <- (remote 2222222222222222) trace=11111111111111111111111111111111
GET /photos <- (remote 2222222222222222) trace=11111111111111111111111111111111
list_photos <- GET /photos <- (remote 2222222222222222) trace=11111111111111111111111111111111
db.query.list_rel_paths_for_libraries <- list_photos <- GET /photos <- (remote 2222222222222222) trace=11111111111111111111111111111111
db.query.get_tag_counts_batch <- list_photos <- GET /photos <- (remote 2222222222222222) trace=11111111111111111111111111111111
== llm.request attribute presence ==
op=chat_stream model=qwen3.8-27b-abliterated provider=llamacpp status=200 tokens_in=11148 parent=ai.chat.turn.stream
op=embeddings model=embed provider=llamacpp status=200 tokens_in=None parent=ai.rag.search_daily_summaries
op=chat model=qwen3.8-27b-abliterated provider=llamacpp status=200 tokens_in=10289 parent=ai.agentic.loop
op=chat model=qwen3.8-27b-abliterated provider=llamacpp status=200 tokens_in=10480 parent=ai.agentic.loop
op=embeddings model=embed provider=llamacpp status=200 tokens_in=None parent=ai.agentic.loop
op=embeddings model=embed provider=llamacpp status=200 tokens_in=None parent=ai.agentic.loop
op=chat model=qwen3.8-27b-abliterated provider=llamacpp status=200 tokens_in=5089 parent=ai.agentic.loop
op=chat model=qwen3.8-27b-abliterated provider=llamacpp status=200 tokens_in=10672 parent=ai.agentic.loop
op=chat model=qwen3.8-27b-abliterated provider=llamacpp status=200 tokens_in=10911 parent=ai.agentic.loop
op=chat model=qwen3.8-27b-abliterated provider=llamacpp status=200 tokens_in=10340 parent=ai.agentic.loop
op=chat model=qwen3.8-27b-abliterated provider=llamacpp status=200 tokens_in=9858 parent=ai.agentic.loop
op=embeddings model=embed provider=llamacpp status=200 tokens_in=None parent=ai.rag.search_daily_summaries
== turn / insight spans: llm.backend + llm.model ==
ai.chat.turn.stream: llm.backend=None llm.model=None backend=None model=None
ai.insight.generate_agentic: llm.backend=None llm.model=None backend=local model=qwen3.8-27b-abliterated
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# Replays the #137 baseline request set against the after-capture server.
set -u
W=$(dirname "$0"); B=http://127.0.0.1:18080
T=$(python3 $W/mint_jwt.py); H="Authorization: Bearer $T"
echo "1) photos"; curl -s -o /dev/null -w "%{http_code}\n" -H "$H" "$B/photos?path=Colleen&recursive=true&limit=20"
echo "2) memories"; curl -s -o /dev/null -w "%{http_code}\n" -H "$H" "$B/memories?path=.&recursive=true"
echo "3) agentic"; R=$(curl -s -H "$H" -H 'Content-Type: application/json' -d '{"file_path":"Photo06172345.jpg","backend":"local"}' "$B/insights/generate/agentic"); echo "$R"
JOB=$(echo "$R" | jq -r '.job_id // empty')
if [ -n "$JOB" ]; then
for i in $(seq 1 120); do S=$(curl -s -H "$H" "$B/insights/generation/status?job_id=$JOB" | jq -r .status); [ "$S" != "running" ] && [ "$S" != "pending" ] && break; sleep 5; done
echo " job $JOB -> $S"
fi
echo "4) chat stream"; curl -sN -H "$H" -H 'Content-Type: application/json' -d '{"file_path":"Photo06172345.jpg","user_message":"What was I wearing in this photo?","backend":"local"}' "$B/insights/chat/stream" > $W/chat.sse; echo " deltas=$(grep -c '^event: text' $W/chat.sse) done=$(grep -c '^event: done' $W/chat.sse) errors=$(grep -c '^event: error' $W/chat.sse)"
echo "5) traceparent probe"; curl -s -o /dev/null -w "%{http_code}\n" -H "$H" -H 'traceparent: 00-11111111111111111111111111111111-2222222222222222-01' "$B/photos?path=Colleen&recursive=true&limit=5"
@@ -0,0 +1,918 @@
[
{
"operation": "generate_video_thumbnail",
"span_count": 414,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "(none)",
"attribute_keys": [
"file-name",
"library",
"otel.scope.name",
"span.kind",
"type"
],
"parents": {
"(remote)": 414
},
"root_count": 0
},
{
"operation": "db.query.get_job_by_id",
"span_count": 16,
"trace_count": 16,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"GET /insights/generation/status": 16
},
"root_count": 0
},
{
"operation": "GET /insights/generation/status",
"span_count": 16,
"trace_count": 16,
"kind": "server",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"http.client_ip",
"http.flavor",
"http.host",
"http.method",
"http.route",
"http.scheme",
"http.status_code",
"http.target",
"http.user_agent",
"otel.scope.name",
"otel.status_code",
"request_id",
"span.kind",
"trace_id"
],
"parents": {},
"root_count": 16
},
{
"operation": "llm.request",
"span_count": 12,
"trace_count": 2,
"kind": "client",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"http.status_code",
"llm.model",
"llm.operation",
"llm.provider",
"llm.tokens.input",
"llm.tokens.output",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"ai.chat.turn.stream": 1,
"ai.rag.search_daily_summaries": 2,
"ai.agentic.loop": 9
},
"root_count": 0
},
{
"operation": "db.query.get_tag_counts_batch",
"span_count": 4,
"trace_count": 2,
"kind": "internal",
"scope": "db/image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"file_count",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"list_photos": 4
},
"root_count": 0
},
{
"operation": "list_photos",
"span_count": 4,
"trace_count": 2,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"camera_make",
"camera_model",
"date_from",
"date_to",
"exclude_tag_ids",
"file_count",
"gps_lat",
"gps_lon",
"gps_radius_km",
"lens_model",
"library",
"media_type",
"optimization.batch_tags",
"optimization.db_sort",
"otel.scope.name",
"otel.status_code",
"pagination.enabled",
"pagination.limit",
"pagination.offset",
"path",
"recursive",
"returned_count",
"sort",
"span.kind",
"tag_filter_mode",
"tag_ids",
"total_count"
],
"parents": {
"(remote)": 1,
"GET /photos": 3
},
"root_count": 0
},
{
"operation": "db.query.list_rel_paths_for_libraries",
"span_count": 4,
"trace_count": 2,
"kind": "internal",
"scope": "db/image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"list_photos": 4
},
"root_count": 0
},
{
"operation": "db.query.get_persona",
"span_count": 4,
"trace_count": 2,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"ai.chat.turn.stream": 1,
"http.insights.generate_agentic": 1,
"ai.agentic.loop": 1,
"ai.insight.generate_agentic": 1
},
"root_count": 0
},
{
"operation": "db.query.get_prefs",
"span_count": 4,
"trace_count": 3,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"POST /insights/chat/stream": 1,
"ai.insight.generate_agentic": 1,
"http.insights.generate_agentic": 1,
"list_memories": 1
},
"root_count": 0
},
{
"operation": "db.query.count_for_library",
"span_count": 4,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"watcher.initial_probe": 4
},
"root_count": 0
},
{
"operation": "db.query.list_entities",
"span_count": 4,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"ai.agentic.loop": 4
},
"root_count": 0
},
{
"operation": "db.query.get_memories_in_window",
"span_count": 4,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"list_memories": 4
},
"root_count": 0
},
{
"operation": "GET /photos",
"span_count": 3,
"trace_count": 2,
"kind": "server",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"http.client_ip",
"http.flavor",
"http.host",
"http.method",
"http.route",
"http.scheme",
"http.status_code",
"http.target",
"http.user_agent",
"otel.scope.name",
"otel.status_code",
"request_id",
"span.kind",
"trace_id"
],
"parents": {
"(remote)": 2
},
"root_count": 1
},
{
"operation": "db.query.get_exif",
"span_count": 3,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"ai.agentic.loop": 2,
"ai.insight.generate_agentic": 1
},
"root_count": 0
},
{
"operation": "db.insert.upsert_entity",
"span_count": 3,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"ai.agentic.loop": 2,
"ai.insight.generate_agentic": 1
},
"root_count": 0
},
{
"operation": "db.query.has_any_faces",
"span_count": 2,
"trace_count": 2,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"ai.chat.turn.stream": 1,
"ai.insight.generate_agentic": 1
},
"root_count": 0
},
{
"operation": "db.query.get_location_count",
"span_count": 2,
"trace_count": 2,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"ai.chat.turn.stream": 1,
"ai.insight.generate_agentic": 1
},
"root_count": 0
},
{
"operation": "db.query.get_event_count",
"span_count": 2,
"trace_count": 2,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"ai.chat.turn.stream": 1,
"ai.insight.generate_agentic": 1
},
"root_count": 0
},
{
"operation": "db.query.has_any_summaries",
"span_count": 2,
"trace_count": 2,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"ai.chat.turn.stream": 1,
"ai.insight.generate_agentic": 1
},
"root_count": 0
},
{
"operation": "db.query.find_similar_summaries_with_time_weight",
"span_count": 2,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"ai.rag.search_daily_summaries": 2
},
"root_count": 0
},
{
"operation": "ai.rag.search_daily_summaries",
"span_count": 2,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"date",
"limit",
"otel.scope.name",
"otel.status_code",
"query",
"results_count",
"span.kind",
"time_weighted"
],
"parents": {
"ai.agentic.loop": 2
},
"root_count": 0
},
{
"operation": "db.insert.upsert_photo_link",
"span_count": 2,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"ai.agentic.loop": 2
},
"root_count": 0
},
{
"operation": "db.insert.upsert_fact",
"span_count": 2,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"ai.agentic.loop": 2
},
"root_count": 0
},
{
"operation": "db.update.update_training_messages",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"ai.chat.turn.stream": 1
},
"root_count": 0
},
{
"operation": "ai.chat.turn.stream",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"file_path",
"library_id",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"POST /insights/chat/stream": 1
},
"root_count": 0
},
{
"operation": "POST /insights/chat/stream",
"span_count": 1,
"trace_count": 1,
"kind": "server",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"http.client_ip",
"http.flavor",
"http.host",
"http.method",
"http.route",
"http.scheme",
"http.status_code",
"http.target",
"http.user_agent",
"otel.scope.name",
"otel.status_code",
"request_id",
"span.kind",
"trace_id"
],
"parents": {},
"root_count": 1
},
{
"operation": "db.query.get_current_insight_for_library",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"ai.chat.turn.stream": 1
},
"root_count": 0
},
{
"operation": "watcher.initial_probe",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "(none)",
"attribute_keys": [
"otel.scope.name",
"span.kind"
],
"parents": {},
"root_count": 1
},
{
"operation": "db.query.get_tags_for_path",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"path",
"span.kind"
],
"parents": {
"ai.insight.generate_agentic": 1
},
"root_count": 0
},
{
"operation": "db.query.get_links_for_photo",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"ai.agentic.loop": 1
},
"root_count": 0
},
{
"operation": "db.query.get_active_job",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"http.insights.generate_agentic": 1
},
"root_count": 0
},
{
"operation": "ai.agentic.loop",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"iterations_used",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"ai.insight.generate_agentic": 1
},
"root_count": 0
},
{
"operation": "db.insert.store_insight",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"ai.insight.generate_agentic": 1
},
"root_count": 0
},
{
"operation": "db.update.complete_job",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"http.insights.generate_agentic": 1
},
"root_count": 0
},
{
"operation": "db.update.update_facts_insight_id",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"ai.insight.generate_agentic": 1
},
"root_count": 0
},
{
"operation": "db.query.resolve_content_hash",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"ai.agentic.loop": 1
},
"root_count": 0
},
{
"operation": "ai.insight.generate_agentic",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"backend",
"file_path",
"images_inline",
"max_iterations",
"model",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"http.insights.generate_agentic": 1
},
"root_count": 0
},
{
"operation": "http.insights.generate_agentic",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"backend",
"file_path",
"job_id",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"POST /insights/generate/agentic": 1
},
"root_count": 0
},
{
"operation": "POST /insights/generate/agentic",
"span_count": 1,
"trace_count": 1,
"kind": "server",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"http.client_ip",
"http.flavor",
"http.host",
"http.method",
"http.route",
"http.scheme",
"http.status_code",
"http.target",
"http.user_agent",
"otel.scope.name",
"otel.status_code",
"request_id",
"span.kind",
"trace_id"
],
"parents": {},
"root_count": 1
},
{
"operation": "db.query.faces_for_hash",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"content_hash",
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"ai.agentic.loop": 1
},
"root_count": 0
},
{
"operation": "db.update.cancel_active_jobs",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"http.insights.generate_agentic": 1
},
"root_count": 0
},
{
"operation": "db.insert.create_job",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"http.insights.generate_agentic": 1
},
"root_count": 0
},
{
"operation": "list_memories",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"otel.scope.name",
"otel.status_code",
"span.kind"
],
"parents": {
"GET /memories": 1
},
"root_count": 0
},
{
"operation": "GET /memories",
"span_count": 1,
"trace_count": 1,
"kind": "server",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"http.client_ip",
"http.flavor",
"http.host",
"http.method",
"http.route",
"http.scheme",
"http.status_code",
"http.target",
"http.user_agent",
"otel.scope.name",
"otel.status_code",
"request_id",
"span.kind",
"trace_id"
],
"parents": {},
"root_count": 1
}
]
@@ -0,0 +1 @@
{"data":[{"traceID":"4e4c40ef185bcdc4bcf4dacbcd661891","spans":[{"traceID":"4e4c40ef185bcdc4bcf4dacbcd661891","spanID":"91c3816dc20cf18a","operationName":"db.query.get_job_by_id","references":[{"refType":"CHILD_OF","traceID":"4e4c40ef185bcdc4bcf4dacbcd661891","spanID":"dbe4222c262384c2"}],"startTime":1790716633530356,"duration":43,"tags":[{"key":"busy_ns","type":"int64","value":38316},{"key":"code.file.path","type":"string","value":"src/otel.rs"},{"key":"code.line.number","type":"int64","value":185},{"key":"code.module.name","type":"string","value":"image_api::otel"},{"key":"db.operation","type":"string","value":"get_job_by_id"},{"key":"db.query_type","type":"string","value":"query"},{"key":"idle_ns","type":"int64","value":5976},{"key":"otel.scope.name","type":"string","value":"image-server"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"internal"},{"key":"target","type":"string","value":"image_api::otel"},{"key":"thread.id","type":"int64","value":20},{"key":"thread.name","type":"string","value":"actix-rt|system:0|arbiter:4"}],"logs":[],"processID":"p1","warnings":null},{"traceID":"4e4c40ef185bcdc4bcf4dacbcd661891","spanID":"dbe4222c262384c2","operationName":"GET /insights/generation/status","references":[],"startTime":1790716633530283,"duration":152,"tags":[{"key":"busy_ns","type":"int64","value":110731},{"key":"code.file.path","type":"string","value":"/home/cameron/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tracing-actix-web-0.7.25/src/root_span_builder.rs"},{"key":"code.line.number","type":"int64","value":44},{"key":"code.module.name","type":"string","value":"tracing_actix_web::root_span_builder"},{"key":"http.client_ip","type":"string","value":"127.0.0.1"},{"key":"http.flavor","type":"string","value":"1.1"},{"key":"http.host","type":"string","value":"127.0.0.1:18080"},{"key":"http.method","type":"string","value":"GET"},{"key":"http.route","type":"string","value":"/insights/generation/status"},{"key":"http.scheme","type":"string","value":"http"},{"key":"http.status_code","type":"int64","value":200},{"key":"http.target","type":"string","value":"/insights/generation/status?job_id=380"},{"key":"http.user_agent","type":"string","value":"curl/8.5.0"},{"key":"idle_ns","type":"int64","value":42074},{"key":"otel.scope.name","type":"string","value":"image-server"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"request_id","type":"string","value":"7dbbfceb-000a-4bc0-9f1e-b9ef9612cea3"},{"key":"span.kind","type":"string","value":"server"},{"key":"target","type":"string","value":"tracing_actix_web::root_span_builder"},{"key":"thread.id","type":"int64","value":20},{"key":"thread.name","type":"string","value":"actix-rt|system:0|arbiter:4"},{"key":"trace_id","type":"string","value":"4e4c40ef185bcdc4bcf4dacbcd661891"}],"logs":[],"processID":"p1","warnings":null}],"processes":{"p1":{"serviceName":"image-server","tags":[{"key":"service.version","type":"string","value":"1.5.0"},{"key":"telemetry.sdk.language","type":"string","value":"rust"},{"key":"telemetry.sdk.name","type":"string","value":"opentelemetry"},{"key":"telemetry.sdk.version","type":"string","value":"0.31.0"}]}},"warnings":null},{"traceID":"9b6f5cad283c0ee75cfd796962a94047","spans":[{"traceID":"9b6f5cad283c0ee75cfd796962a94047","spanID":"f225af9372244bce","operationName":"db.query.get_job_by_id","references":[{"refType":"CHILD_OF","traceID":"9b6f5cad283c0ee75cfd796962a94047","spanID":"e33154be38ea4319"}],"startTime":1790716648578964,"duration":78,"tags":[{"key":"busy_ns","type":"int64","value":72519},{"key":"code.file.path","type":"string","value":"src/otel.rs"},{"key":"code.line.number","type":"int64","value":185},{"key":"code.module.name","type":"string","value":"image_api::otel"},{"key":"db.operation","type":"string","value":"get_job_by_id"},{"key":"db.query_type","type":"string","value":"query"},{"key":"idle_ns","type":"int64","value":7019},{"key":"otel.scope.name","type":"string","value":"image-server"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"internal"},{"key":"target","type":"string","value":"image_api::otel"},{"key":"thread.id","type":"int64","value":23},{"key":"thread.name","type":"string","value":"actix-rt|system:0|arbiter:7"}],"logs":[],"processID":"p1","warnings":null},{"traceID":"9b6f5cad283c0ee75cfd796962a94047","spanID":"e33154be38ea4319","operationName":"GET /insights/generation/status","references":[],"startTime":1790716648578868,"duration":219,"tags":[{"key":"busy_ns","type":"int64","value":157935},{"key":"code.file.path","type":"string","value":"/home/cameron/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tracing-actix-web-0.7.25/src/root_span_builder.rs"},{"key":"code.line.number","type":"int64","value":44},{"key":"code.module.name","type":"string","value":"tracing_actix_web::root_span_builder"},{"key":"http.client_ip","type":"string","value":"127.0.0.1"},{"key":"http.flavor","type":"string","valLine truncated
@@ -0,0 +1,129 @@
# Pre-migration span baseline (ticket #137, map #135)
Captured from **master @ `da3901c`** ("before" the tracing migration) so the
post-migration output can be diffed for parity. See map #135 for the
migration's destination.
## What was run
- **Revision:** `da3901c` (merge of "Prefs: owner defaults"), the last
master commit before `feature/tracing-migration`.
- **Build:** `cargo build --release` — release is required; the pre-migration
OTel init is behind `#[cfg(not(debug_assertions))]`, so debug builds are a
no-op (no global tracer provider, no spans).
- **Exporter:** local Jaeger all-in-one (`jaegertracing/all-in-one:latest`),
OTLP/gRPC on host `:14317` (container `:4317`), UI `:16686`. Prod SigNoz
(`OTLP_OTLS_ENDPOINT=http://192.168.10.50:4317`) was overridden deliberately.
- **DB:** a private `.backup` copy of the live `auth.db` (21,298 `image_exif`
rows, 4 NAS-backed libraries). The live deployment (`image-rs.service`) was
left running and untouched; all output dirs (thumbs/gifs/video/reels) point
at a temp dir.
- **LLM backend:** the live local stack — `LLM_BACKEND=llamacpp`,
`LLAMA_SWAP_URL=http://192.168.10.50:9292/v1`, model
`qwen3.8-27b-abliterated`.
### Deviation from pristine `da3901c`
The binary was built from `da3901c` with **one span-neutral change**: the
hardcoded secondary listener `.bind("localhost:8088")` was moved to
`.bind("localhost:18088")` because the live `image-rs.service` owns `:8088`.
The listen port cannot affect span production. Everything else is unmodified.
### Requests exercised (the representative set)
All with `Authorization: Bearer <JWT>` (minted with the live `SECRET_KEY`,
`sub=2`).
| # | Request | Span trace(s) |
|---|---------|---------------|
| 1 | `GET /photos?path=Colleen&recursive=true&limit=20` | `cb07eeec…` (3 spans) |
| 2 | `GET /memories?path=.&recursive=true` | `44b3b67d…` (6 spans) |
| 3 | `POST /insights/generate/agentic` `{file_path:"Photo06172345.jpg",backend:"local"}` → **202 job_id=380** (async) | `948d2756…` (HTTP span) **and** `9b520713…` (background, 22 spans) |
| 4 | `POST /insights/chat/stream` `{file_path:"Photo06172345.jpg",user_message:"What was I wearing in this photo?",backend:"local"}` (SSE, 88 text deltas, `done`) | fragmented — see below |
| 5 | `GET /photos?path=Colleen&recursive=true&limit=5` **with** `traceparent: 00-1111…1111-2222…2222-01` | `11111111…` (3 spans) |
## Span shape (the "before")
**Service:** `image-server` (resource `service.name`), version from
`CARGO_PKG_VERSION`. No per-request HTTP server span exists — the migration's
`tracing-actix-web` middleware (#139) adds one.
**Span names**
- Operation / "root" spans (created manually in handlers): `list_photos`,
`list_memories`, `http.insights.generate_agentic`, `ai.insight.generate_agentic`,
`ai.agentic.loop`, `ai.rag.search_daily_summaries`, `generate_video_thumbnail`.
- DB spans: `db.{query_type}.{operation}` — `query_type ∈ {query, insert, update}`,
`operation` = snake_case DAO method (`get_exif`, `store_insight`, …).
Tracer scope (`otel.scope.name`) is `db`.
- LLM spans: `llm.request`, tracer scope `image-server`.
**`span.kind`:** `internal` for every operation/DB span; `client` for
`llm.request`.
**Key attributes**
- Every span: `otel.scope.name` (`image-server` or `db`), `otel.status_code`
(`OK` / unset), `span.kind`.
- DB (`db.*`): `db.query_type`, `db.operation`.
- LLM (`llm.request`): `llm.model` (`qwen3.8-27b-abliterated`),
`llm.provider` (`llamacpp`), `llm.operation` (`chat` for tool calls,
`chat_stream` for the SSE turn), `llm.tokens.input`, `llm.tokens.output`,
`http.status_code`.
- `list_photos`: rich request shape — `path`, `recursive`, `sort`, `library`,
`media_type`, `pagination.limit/offset/enabled`, `file_count`,
`returned_count`, `total_count`, `optimization.batch_tags`,
`optimization.db_sort`, `tag_filter_mode`, plus empty camera/gps/date fields.
- `ai.insight.generate_agentic`: `backend`, `file_path`, `images_inline`,
`max_iterations`, `model`. `ai.agentic.loop`: `iterations_used`.
- `generate_video_thumbnail`: `file-name`, `library`, `type`.
- `ai.rag.search_daily_summaries`: `query`, `date`, `limit`, `results_count`,
`time_weighted`.
**Nesting — the headline finding.** Parent/child threading is *inconsistent*:
1. **No HTTP root span.** Handlers open an operation-named span as a root.
2. **Most DB spans are orphaned into their own single-span trace** (see
`span-inventory.json`: `trace_count == span_count` for most ops).
`db.query.get_prefs`, `db.query.count_for_library`, `db.insert.store_insight`,
`db.update.*`, etc. each form a separate trace.
3. **Threading works in pockets.** `list_photos` (→ 2 DB children),
`list_memories` (→ `get_prefs` + 4× `get_memories_in_window`), and the
agentic loop (`ai.insight.generate_agentic` → `ai.agentic.loop` →
`llm.request` + DB spans) nest correctly.
4. **Async work loses the trace.** `POST /insights/generate/agentic` returns
**202** with `http.insights.generate_agentic` in one trace; the background
generation is a *different root trace* (`ai.insight.generate_agentic`).
The streaming chat path is likewise fragmented — its DB calls and
`llm.request` land as separate single-span traces, with **no handler span
at all**.
5. **One 222-span trace** (`b94440d1…`) holds the startup thumbnail sweep —
all `generate_video_thumbnail` spans share one trace.
**Incoming propagation WORKS.** With a caller-supplied W3C `traceparent`, the
`list_photos` span is created in the caller's trace-id and references the
remote span-id as `CHILD_OF` — i.e. the request joins the upstream trace
correctly. This is the behavior the migration must preserve.
## Files
- `traces-no-traceparent.json` — all 33 traces from requests 1–4 (Jaeger
`/api/traces` dump; requests made without an incoming traceparent).
- `traces-with-traceparent.json` — includes the propagation test (request 5).
- `span-inventory.json` — per-operation summary: span count, trace count,
kind, scope, status, attribute keys. The direct input for a before/after
shape diff.
## Re-capturing
1. Build `da3901c` release (or the branch under test for the "after").
2. `docker run -d --name jaeger -p 16686:16686 -p 14317:4317 jaegertracing/all-in-one:latest`.
3. Run the server against a **copy** of the DB with
`OTLP_OTLS_ENDPOINT=http://localhost:14317` and a temp `BIND_URL`; remember
the hardcoded secondary `localhost:8088` bind collides with the live
service (patch it, or stop the service).
4. Mint a JWT (`HS256`, `{sub, exp}`, `SECRET_KEY`) and replay the request set
in the table above.
5. `curl 'http://localhost:16686/api/traces?service=image-server&limit=600'`
→ regenerate `span-inventory.json` and diff operation names, kinds,
attribute keys, and nesting.
@@ -0,0 +1,590 @@
[
{
"operation": "generate_video_thumbnail",
"span_count": 222,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "(none)",
"attribute_keys": [
"file-name",
"library",
"otel.scope.name",
"span.kind",
"type"
]
},
{
"operation": "llm.request",
"span_count": 8,
"trace_count": 3,
"kind": "client",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"http.status_code",
"llm.model",
"llm.operation",
"llm.provider",
"llm.tokens.input",
"llm.tokens.output",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.query.count_for_library",
"span_count": 4,
"trace_count": 4,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.query.get_memories_in_window",
"span_count": 4,
"trace_count": 1,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.query.get_persona",
"span_count": 4,
"trace_count": 4,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.query.get_prefs",
"span_count": 4,
"trace_count": 4,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.insert.upsert_entity",
"span_count": 2,
"trace_count": 1,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.query.get_event_count",
"span_count": 2,
"trace_count": 2,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.query.get_exif",
"span_count": 2,
"trace_count": 2,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.query.get_location_count",
"span_count": 2,
"trace_count": 2,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.query.has_any_faces",
"span_count": 2,
"trace_count": 2,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.query.has_any_summaries",
"span_count": 2,
"trace_count": 2,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.query.list_entities",
"span_count": 2,
"trace_count": 1,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "ai.agentic.loop",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"iterations_used",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "ai.insight.generate_agentic",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"backend",
"file_path",
"images_inline",
"max_iterations",
"model",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "ai.rag.search_daily_summaries",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"date",
"limit",
"otel.scope.name",
"otel.status_code",
"query",
"results_count",
"span.kind",
"time_weighted"
]
},
{
"operation": "db.insert.create_job",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.insert.store_insight",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.insert.upsert_fact",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.insert.upsert_photo_link",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.query.faces_for_hash",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"content_hash",
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.query.find_relevant_events_hybrid",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.query.find_similar_summaries_with_time_weight",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.query.get_active_job",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.query.get_current_insight_for_library",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.query.get_links_for_photo",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.query.get_tag_counts_batch",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"file_count",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.query.get_tags_for_path",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"path",
"span.kind"
]
},
{
"operation": "db.query.list_rel_paths_for_libraries",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.query.resolve_content_hash",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.update.cancel_active_jobs",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.update.complete_job",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.update.update_facts_insight_id",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "db.update.update_training_messages",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "db",
"status": "OK",
"attribute_keys": [
"db.operation",
"db.query_type",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "http.insights.generate_agentic",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"backend",
"file_path",
"job_id",
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "list_memories",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"otel.scope.name",
"otel.status_code",
"span.kind"
]
},
{
"operation": "list_photos",
"span_count": 1,
"trace_count": 1,
"kind": "internal",
"scope": "image-server",
"status": "OK",
"attribute_keys": [
"camera_make",
"camera_model",
"date_from",
"date_to",
"exclude_tag_ids",
"file_count",
"gps_lat",
"gps_lon",
"gps_radius_km",
"lens_model",
"library",
"media_type",
"optimization.batch_tags",
"optimization.db_sort",
"otel.scope.name",
"otel.status_code",
"pagination.enabled",
"pagination.limit",
"pagination.offset",
"path",
"recursive",
"returned_count",
"sort",
"span.kind",
"tag_filter_mode",
"tag_ids",
"total_count"
]
}
]
@@ -0,0 +1 @@
{"data":[{"traceID":"08455e5e41fe244713bdbce55178fb74","spans":[{"traceID":"08455e5e41fe244713bdbce55178fb74","spanID":"53b66e719b4da88b","operationName":"db.query.get_persona","references":[],"startTime":1790704286497358,"duration":97,"tags":[{"key":"db.operation","type":"string","value":"get_persona"},{"key":"db.query_type","type":"string","value":"query"},{"key":"otel.scope.name","type":"string","value":"db"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"internal"}],"logs":[],"processID":"p1","warnings":null}],"processes":{"p1":{"serviceName":"image-server","tags":[{"key":"service.version","type":"string","value":"1.5.0"},{"key":"telemetry.sdk.language","type":"string","value":"rust"},{"key":"telemetry.sdk.name","type":"string","value":"opentelemetry"},{"key":"telemetry.sdk.version","type":"string","value":"0.31.0"}]}},"warnings":null},{"traceID":"33dd886ca35f036dc130e157d18cde88","spans":[{"traceID":"33dd886ca35f036dc130e157d18cde88","spanID":"b9886203d0340833","operationName":"db.insert.store_insight","references":[],"startTime":1790704373774193,"duration":6036,"tags":[{"key":"db.operation","type":"string","value":"store_insight"},{"key":"db.query_type","type":"string","value":"insert"},{"key":"otel.scope.name","type":"string","value":"db"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"internal"}],"logs":[],"processID":"p1","warnings":null}],"processes":{"p1":{"serviceName":"image-server","tags":[{"key":"service.version","type":"string","value":"1.5.0"},{"key":"telemetry.sdk.language","type":"string","value":"rust"},{"key":"telemetry.sdk.name","type":"string","value":"opentelemetry"},{"key":"telemetry.sdk.version","type":"string","value":"0.31.0"}]}},"warnings":null},{"traceID":"6a3205b66ce3f4f25e849804437b4d2a","spans":[{"traceID":"6a3205b66ce3f4f25e849804437b4d2a","spanID":"689b895b8779feee","operationName":"db.query.get_event_count","references":[],"startTime":1790704423171303,"duration":458,"tags":[{"key":"db.operation","type":"string","value":"get_event_count"},{"key":"db.query_type","type":"string","value":"query"},{"key":"otel.scope.name","type":"string","value":"db"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"internal"}],"logs":[],"processID":"p1","warnings":null}],"processes":{"p1":{"serviceName":"image-server","tags":[{"key":"service.version","type":"string","value":"1.5.0"},{"key":"telemetry.sdk.language","type":"string","value":"rust"},{"key":"telemetry.sdk.name","type":"string","value":"opentelemetry"},{"key":"telemetry.sdk.version","type":"string","value":"0.31.0"}]}},"warnings":null},{"traceID":"9f649ce92d6b31b1e85900a052388fd9","spans":[{"traceID":"9f649ce92d6b31b1e85900a052388fd9","spanID":"36e0c7e5a2ee06b0","operationName":"llm.request","references":[],"startTime":1790704423320965,"duration":31634307,"tags":[{"key":"http.status_code","type":"int64","value":200},{"key":"llm.model","type":"string","value":"qwen3.8-27b-abliterated"},{"key":"llm.operation","type":"string","value":"chat_stream"},{"key":"llm.provider","type":"string","value":"llamacpp"},{"key":"llm.tokens.input","type":"int64","value":6904},{"key":"llm.tokens.output","type":"int64","value":1422},{"key":"otel.scope.name","type":"string","value":"image-server"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"client"}],"logs":[],"processID":"p1","warnings":null}],"processes":{"p1":{"serviceName":"image-server","tags":[{"key":"service.version","type":"string","value":"1.5.0"},{"key":"telemetry.sdk.language","type":"string","value":"rust"},{"key":"telemetry.sdk.name","type":"string","value":"opentelemetry"},{"key":"telemetry.sdk.version","type":"string","value":"0.31.0"}]}},"warnings":null},{"traceID":"b0339bf5be5b7ca90eded50c4c30ea8d","spans":[{"traceID":"b0339bf5be5b7ca90eded50c4c30ea8d","spanID":"4db108684f58fcaa","operationName":"db.update.update_training_messages","references":[],"startTime":1790704454955816,"duration":1631,"tags":[{"key":"db.operation","type":"string","value":"update_training_messages"},{"key":"db.query_type","type":"string","value":"update"},{"key":"otel.scope.name","type":"string","value":"db"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"internal"}],"logs":[],"processID":"p1","warnings":null}],"processes":{"p1":{"serviceName":"image-server","tags":[{"key":"service.version","type":"string","value":"1.5.0"},{"key":"telemetry.sdk.language","type":"string","value":"rust"},{"key":"telemetry.sdk.name","type":"string","value":"opentelemetry"},{"key":"telemetry.sdk.version","type":"string","value":"0.31.0"}]}},"warnings":null},{"traceID":"e710521acc2fac9cf41374cb56cc2e8c","spans":[{"traceID":"e710521acc2fac9cf41374cb56cc2e8c","spanID":"29772615a01901bf","operationName":"db.query.count_for_library","references":[],"startTime":1790704268693001,"Line truncated
@@ -0,0 +1 @@
{"data":[{"traceID":"9e81fc7e14eaf9f372040183caf7b093","spans":[{"traceID":"9e81fc7e14eaf9f372040183caf7b093","spanID":"2a3fd729253fcef9","operationName":"db.query.count_for_library","references":[],"startTime":1790704268692143,"duration":617,"tags":[{"key":"db.operation","type":"string","value":"count_for_library"},{"key":"db.query_type","type":"string","value":"query"},{"key":"otel.scope.name","type":"string","value":"db"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"internal"}],"logs":[],"processID":"p1","warnings":null}],"processes":{"p1":{"serviceName":"image-server","tags":[{"key":"service.version","type":"string","value":"1.5.0"},{"key":"telemetry.sdk.language","type":"string","value":"rust"},{"key":"telemetry.sdk.name","type":"string","value":"opentelemetry"},{"key":"telemetry.sdk.version","type":"string","value":"0.31.0"}]}},"warnings":null},{"traceID":"948d27569252087184047aafb327e9f2","spans":[{"traceID":"948d27569252087184047aafb327e9f2","spanID":"18359fc0dd6057b2","operationName":"http.insights.generate_agentic","references":[],"startTime":1790704286474704,"duration":22781,"tags":[{"key":"backend","type":"string","value":"local"},{"key":"file_path","type":"string","value":"Photo06172345.jpg"},{"key":"job_id","type":"int64","value":380},{"key":"otel.scope.name","type":"string","value":"image-server"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"internal"}],"logs":[],"processID":"p1","warnings":null}],"processes":{"p1":{"serviceName":"image-server","tags":[{"key":"service.version","type":"string","value":"1.5.0"},{"key":"telemetry.sdk.language","type":"string","value":"rust"},{"key":"telemetry.sdk.name","type":"string","value":"opentelemetry"},{"key":"telemetry.sdk.version","type":"string","value":"0.31.0"}]}},"warnings":null},{"traceID":"1a5ac0cb51a80bdaf47f9e48c6540eca","spans":[{"traceID":"1a5ac0cb51a80bdaf47f9e48c6540eca","spanID":"43600617e64593a2","operationName":"db.query.get_prefs","references":[],"startTime":1790704286497889,"duration":1098,"tags":[{"key":"db.operation","type":"string","value":"get_prefs"},{"key":"db.query_type","type":"string","value":"query"},{"key":"otel.scope.name","type":"string","value":"db"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"internal"}],"logs":[],"processID":"p1","warnings":null}],"processes":{"p1":{"serviceName":"image-server","tags":[{"key":"service.version","type":"string","value":"1.5.0"},{"key":"telemetry.sdk.language","type":"string","value":"rust"},{"key":"telemetry.sdk.name","type":"string","value":"opentelemetry"},{"key":"telemetry.sdk.version","type":"string","value":"0.31.0"}]}},"warnings":null},{"traceID":"0af6272e8e1e6ded88403fe2761b552c","spans":[{"traceID":"0af6272e8e1e6ded88403fe2761b552c","spanID":"c2ec516d23e8a6df","operationName":"db.query.count_for_library","references":[],"startTime":1790704268693775,"duration":287,"tags":[{"key":"db.operation","type":"string","value":"count_for_library"},{"key":"db.query_type","type":"string","value":"query"},{"key":"otel.scope.name","type":"string","value":"db"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"internal"}],"logs":[],"processID":"p1","warnings":null}],"processes":{"p1":{"serviceName":"image-server","tags":[{"key":"service.version","type":"string","value":"1.5.0"},{"key":"telemetry.sdk.language","type":"string","value":"rust"},{"key":"telemetry.sdk.name","type":"string","value":"opentelemetry"},{"key":"telemetry.sdk.version","type":"string","value":"0.31.0"}]}},"warnings":null},{"traceID":"27cefad7c25390286c6b3fc43c9cd9c7","spans":[{"traceID":"27cefad7c25390286c6b3fc43c9cd9c7","spanID":"eb323a2e3d166157","operationName":"db.query.get_event_count","references":[],"startTime":1790704286604710,"duration":57,"tags":[{"key":"db.operation","type":"string","value":"get_event_count"},{"key":"db.query_type","type":"string","value":"query"},{"key":"otel.scope.name","type":"string","value":"db"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"internal"}],"logs":[],"processID":"p1","warnings":null}],"processes":{"p1":{"serviceName":"image-server","tags":[{"key":"service.version","type":"string","value":"1.5.0"},{"key":"telemetry.sdk.language","type":"string","value":"rust"},{"key":"telemetry.sdk.name","type":"string","value":"opentelemetry"},{"key":"telemetry.sdk.version","type":"string","value":"0.31.0"}]}},"warnings":null},{"traceID":"957362713b0580085adc63b7574a4451","spans":[{"traceID":"957362713b0580085adc63b7574a4451","spanID":"2199d1950cac212a","operationName":"db.query.get_location_count","references":[],"startTime":1790704286604771,"duration":4702,"tags":[{"key":"db.operation","type":"string","value":"get_location_count"},{"key":"db.query_type","type":"string","value":"query"},{"key":"otel.scope.name","type":"string","valuLine truncated
@@ -0,0 +1,5 @@
ALTER TABLE user_ai_prefs DROP COLUMN default_model;
ALTER TABLE user_ai_prefs DROP COLUMN default_backend;
ALTER TABLE user_ai_prefs DROP COLUMN default_persona_id;
ALTER TABLE user_ai_prefs DROP COLUMN reel_span;
ALTER TABLE user_ai_prefs DROP COLUMN reel_max_segments;
@@ -0,0 +1,8 @@
-- Owner defaults: recurring per-user picks that were previously re-sent on
-- every request. All nullable — NULL = keep the current per-request /
-- hard-default behavior.
ALTER TABLE user_ai_prefs ADD COLUMN default_model TEXT;
ALTER TABLE user_ai_prefs ADD COLUMN default_backend TEXT;
ALTER TABLE user_ai_prefs ADD COLUMN default_persona_id TEXT;
ALTER TABLE user_ai_prefs ADD COLUMN reel_span TEXT;
ALTER TABLE user_ai_prefs ADD COLUMN reel_max_segments INTEGER;
+10 -2
View File
@@ -360,11 +360,17 @@ async fn generate_and_store_daily_summary(
span.set_attribute(KeyValue::new("date", date.to_string()));
span.set_attribute(KeyValue::new("contact", contact.to_string()));
span.set_attribute(KeyValue::new("message_count", messages.len() as i64));
// Parent context for the child llm.request spans; the span itself stays
// borrowed here for the attribute updates below.
let llm_cx =
opentelemetry::Context::new().with_remote_span_context(span.span_context().clone());
let (prompt, system_prompt) = build_daily_summary_prompt(contact, date, messages);
// Generate summary with LLM
let summary = ollama.generate(&prompt, Some(system_prompt)).await?;
let summary = ollama
.generate(&llm_cx, &prompt, Some(system_prompt))
.await?;
log::debug!(
"Generated summary for {}: {}",
@@ -382,7 +388,9 @@ async fn generate_and_store_daily_summary(
);
// Embed the stripped summary (store original summary in DB)
let embedding = ollama.generate_embedding(&stripped_summary).await?;
let embedding = ollama
.generate_embedding(&llm_cx, &stripped_summary)
.await?;
span.set_attribute(KeyValue::new(
"embedding_dimensions",
+175 -1
View File
@@ -384,6 +384,36 @@ pub struct ServerModels {
pub default_model: String,
}
/// Apply the stored owner defaults to any request field the client omitted.
/// Resolution order: request param → stored pref → hard default (applied
/// downstream). The request always wins; the pref only fills gaps. One DAO
/// read per request; a failed read silently falls back to hard defaults.
fn apply_owner_defaults(
app_state: &AppState,
model: &mut Option<String>,
backend: &mut Option<String>,
persona_id: &mut Option<String>,
) {
if model.is_some() && backend.is_some() && persona_id.is_some() {
return; // nothing to fill — skip the DB read
}
let Some(mut dao) = app_state.user_ai_prefs_dao.lock().ok() else {
return;
};
let Ok(Some(prefs)) = dao.get_prefs(&opentelemetry::Context::new()) else {
return;
};
if model.is_none() {
*model = prefs.default_model;
}
if backend.is_none() {
*backend = prefs.default_backend;
}
if persona_id.is_none() {
*persona_id = prefs.default_persona_id;
}
}
/// POST /insights/generate - Generate insight for a specific photo (async)
#[post("/insights/generate")]
pub async fn generate_insight_handler(
@@ -396,6 +426,16 @@ pub async fn generate_insight_handler(
let tracer = global_tracer();
let mut span = tracer.start_with_context("http.insights.generate", &parent_context);
let mut request = request.0;
// Stored owner defaults fill what the client omitted (request → pref →
// hard default; the hard default still applies downstream).
apply_owner_defaults(
&app_state,
&mut request.model,
&mut request.backend,
&mut request.persona_id,
);
let normalized_path = normalize_path(&request.file_path);
let library = app_state.primary_library();
let gen_type = InsightGenerationType::Standard;
@@ -714,6 +754,16 @@ pub async fn generate_agentic_insight_handler(
let tracer = global_tracer();
let mut span = tracer.start_with_context("http.insights.generate_agentic", &parent_context);
let mut request = request.0;
// Stored owner defaults fill what the client omitted (request → pref →
// hard default; the hard default still applies downstream).
apply_owner_defaults(
&app_state,
&mut request.model,
&mut request.backend,
&mut request.persona_id,
);
let normalized_path = normalize_path(&request.file_path);
let library = app_state.primary_library();
let gen_type = InsightGenerationType::Agentic;
@@ -1231,6 +1281,16 @@ pub async fn chat_turn_handler(
let mut span = tracer.start_with_context("http.insights.chat", &parent_context);
span.set_attribute(KeyValue::new("file_path", request.file_path.clone()));
let mut request = request.0;
// Stored owner defaults fill what the client omitted (request → pref →
// hard default; the hard default still applies downstream).
apply_owner_defaults(
&app_state,
&mut request.model,
&mut request.backend,
&mut request.persona_id,
);
let library =
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
Ok(Some(lib)) => lib,
@@ -1607,10 +1667,22 @@ pub struct ChatSwitchBranchRequest {
/// Returns `text/event-stream` with one event per chat stream event.
#[post("/insights/chat/stream")]
pub async fn chat_stream_handler(
http_request: HttpRequest,
claims: Claims,
request: web::Json<ChatTurnHttpRequest>,
app_state: web::Data<AppState>,
) -> HttpResponse {
let parent_context = extract_context_from_request(&http_request);
let mut request = request.0;
// Stored owner defaults fill what the client omitted (request → pref →
// hard default; the hard default still applies downstream).
apply_owner_defaults(
&app_state,
&mut request.model,
&mut request.backend,
&mut request.persona_id,
);
let library =
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
Ok(Some(lib)) => lib,
@@ -1646,7 +1718,7 @@ pub async fn chat_stream_handler(
};
let service = app_state.insight_chat.clone();
let events = service.chat_turn_stream(chat_req);
let events = service.chat_turn_stream(chat_req, parent_context);
// Map ChatStreamEvent → SSE frame bytes.
let sse_stream = futures::stream::StreamExt::map(events, |ev| {
@@ -1758,6 +1830,16 @@ pub async fn turn_async_handler(
let mut span = tracer.start_with_context("http.insights.chat_turn_async", &parent_context);
span.set_attribute(KeyValue::new("file_path", request.file_path.clone()));
let mut request = request.0;
// Stored owner defaults fill what the client omitted (request → pref →
// hard default; the hard default still applies downstream).
apply_owner_defaults(
&app_state,
&mut request.model,
&mut request.backend,
&mut request.persona_id,
);
let library =
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
Ok(Some(lib)) => lib,
@@ -2277,3 +2359,95 @@ mod turn_replay_tests {
assert_eq!(info.total_events_pushed, 2);
}
}
#[cfg(test)]
mod owner_defaults_tests {
use super::apply_owner_defaults;
use crate::database::models::UpsertUserAiPrefs;
use crate::database::{SqliteUserAiPrefsDao, UserAiPrefsDao};
use crate::state::AppState;
use std::sync::{Arc, Mutex};
fn prefs_state(prefs: Option<UpsertUserAiPrefs>) -> AppState {
let conn = Arc::new(Mutex::new(crate::database::test::in_memory_db_connection()));
let mut state = AppState::test_state();
state.user_ai_prefs_dao = Arc::new(Mutex::new(Box::new(
SqliteUserAiPrefsDao::from_connection(conn.clone()),
)));
if let Some(p) = prefs {
let mut dao = SqliteUserAiPrefsDao::from_connection(conn);
dao.upsert_prefs(&opentelemetry::Context::new(), &p)
.expect("seed prefs");
}
state
}
fn seeded_defaults() -> UpsertUserAiPrefs {
UpsertUserAiPrefs {
voice: None,
tz_offset_minutes: None,
library: None,
updated_at: 0,
display_tz: None,
default_model: Some("llama3.2:3b".into()),
default_backend: Some("hybrid".into()),
default_persona_id: Some("cabin".into()),
reel_span: None,
reel_max_segments: None,
}
}
#[actix_rt::test]
async fn request_wins_over_stored_pref() {
let state = prefs_state(Some(seeded_defaults()));
let mut model = Some("explicit".to_string());
let mut backend = Some("local".to_string());
let mut persona = Some("explicit-p".to_string());
apply_owner_defaults(&state, &mut model, &mut backend, &mut persona);
assert_eq!(model.as_deref(), Some("explicit"));
assert_eq!(backend.as_deref(), Some("local"));
assert_eq!(persona.as_deref(), Some("explicit-p"));
}
#[actix_rt::test]
async fn pref_fills_omitted_fields() {
let state = prefs_state(Some(seeded_defaults()));
let mut model = None;
let mut backend = None;
let mut persona = None;
apply_owner_defaults(&state, &mut model, &mut backend, &mut persona);
assert_eq!(model.as_deref(), Some("llama3.2:3b"));
assert_eq!(backend.as_deref(), Some("hybrid"));
assert_eq!(persona.as_deref(), Some("cabin"));
}
#[actix_rt::test]
async fn no_pref_row_leaves_fields_none() {
let state = prefs_state(None);
let mut model = None;
let mut backend = None;
let mut persona = None;
apply_owner_defaults(&state, &mut model, &mut backend, &mut persona);
// Hard defaults apply downstream; the pref layer fills nothing.
assert!(model.is_none());
assert!(backend.is_none());
assert!(persona.is_none());
}
#[actix_rt::test]
async fn pref_fills_only_the_fields_it_has() {
let state = prefs_state(Some(UpsertUserAiPrefs {
default_backend: None,
default_persona_id: Some("journal".into()),
..seeded_defaults()
}));
let mut model = None;
let mut backend = Some("local".to_string());
let mut persona = None;
apply_owner_defaults(&state, &mut model, &mut backend, &mut persona);
assert_eq!(model.as_deref(), Some("llama3.2:3b"));
// Explicit request backend is untouched; pref had none anyway.
assert_eq!(backend.as_deref(), Some("local"));
assert_eq!(persona.as_deref(), Some("journal"));
}
}
+230 -34
View File
@@ -1,5 +1,5 @@
use anyhow::{Result, anyhow, bail};
use chrono::Utc;
use chrono::{TimeZone, Utc};
use opentelemetry::KeyValue;
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
use std::collections::HashMap;
@@ -377,7 +377,7 @@ impl InsightChatService {
let (response, prompt_tokens, eval_tokens) = backend
.chat()
.chat_with_tools(messages.clone(), tools.clone())
.chat_with_tools(&loop_cx, messages.clone(), tools.clone())
.await?;
last_prompt_eval_count = prompt_tokens;
last_eval_count = eval_tokens;
@@ -434,7 +434,7 @@ impl InsightChatService {
let synthetic_idx = push_synthetic_final_prompt(&mut messages);
let (final_response, prompt_tokens, eval_tokens) = backend
.chat()
.chat_with_tools(messages.clone(), vec![])
.chat_with_tools(&loop_cx, messages.clone(), vec![])
.await?;
last_prompt_eval_count = prompt_tokens;
last_eval_count = eval_tokens;
@@ -492,6 +492,7 @@ impl InsightChatService {
let title_raw = backend
.chat()
.generate(
&loop_cx,
&title_prompt,
Some(
"You are my long term memory assistant. Use only the information provided. Do not invent details.",
@@ -781,10 +782,11 @@ impl InsightChatService {
pub fn chat_turn_stream(
self: Arc<Self>,
req: ChatTurnRequest,
cx: opentelemetry::Context,
) -> BoxStream<'static, ChatStreamEvent> {
let svc = self;
let s = async_stream::stream! {
match svc.chat_turn_stream_inner(req, Ok).await {
match svc.chat_turn_stream_inner(req, cx, Ok).await {
Ok(mut rx) => {
while let Some(ev) = rx.recv().await {
yield ev;
@@ -806,6 +808,7 @@ impl InsightChatService {
async fn chat_turn_stream_inner<F>(
self: Arc<Self>,
req: ChatTurnRequest,
cx: opentelemetry::Context,
_ev_mapper: F,
) -> Result<tokio::sync::mpsc::Receiver<ChatStreamEvent>>
where
@@ -814,7 +817,7 @@ impl InsightChatService {
let (tx, rx) = tokio::sync::mpsc::channel::<ChatStreamEvent>(64);
let svc = self.clone();
tokio::spawn(async move {
let result = svc.run_streaming_turn(req, tx.clone()).await;
let result = svc.run_streaming_turn(req, cx, tx.clone()).await;
if let Err(e) = result {
let _ = tx.send(ChatStreamEvent::Error(format!("{}", e))).await;
}
@@ -852,7 +855,7 @@ impl InsightChatService {
span.set_attribute(KeyValue::new("library_id", library_id as i64));
let result = svc
.run_streaming_turn_with_entry(req, entry_clone.clone())
.run_streaming_turn_with_entry(req, entry_clone.clone(), &mut span)
.await;
if let Err(ref e) = result {
span.set_attribute(KeyValue::new("status", "error"));
@@ -882,6 +885,7 @@ impl InsightChatService {
self: Arc<Self>,
req: ChatTurnRequest,
entry: Arc<TurnEntry>,
turn_span: &mut opentelemetry::global::BoxedSpan,
) -> Result<()> {
if req.user_message.trim().is_empty() {
bail!("user_message must not be empty");
@@ -911,11 +915,11 @@ impl InsightChatService {
if req.regenerate || existing_insight.is_none() {
return self
.run_bootstrap_streaming_with_entry(req, normalized, entry)
.run_bootstrap_streaming_with_entry(req, normalized, entry, turn_span)
.await;
}
let insight = existing_insight.expect("just checked Some above");
self.run_continuation_streaming_with_entry(req, normalized, insight, entry)
self.run_continuation_streaming_with_entry(req, normalized, insight, entry, turn_span)
.await
}
@@ -926,6 +930,7 @@ impl InsightChatService {
normalized: String,
insight: crate::database::models::PhotoInsight,
entry: Arc<TurnEntry>,
turn_span: &mut opentelemetry::global::BoxedSpan,
) -> Result<()> {
let active_persona = req
.persona_id
@@ -981,6 +986,12 @@ impl InsightChatService {
};
let backend = self.generator.resolve_backend(kind, &overrides).await?;
let model_used = backend.model().to_string();
// llm.* attributes on the turn-level span (issue #130): clients can't
// know local-vs-hybrid, but the turn knows its resolved backend.
turn_span.set_attribute(KeyValue::new("llm.backend", kind.as_str()));
turn_span.set_attribute(KeyValue::new("llm.model", model_used.clone()));
let turn_cx = opentelemetry::Context::new()
.with_remote_span_context(turn_span.span_context().clone());
let local_first_user_has_image = messages
.iter()
@@ -1030,6 +1041,7 @@ impl InsightChatService {
&active_persona,
max_iterations,
&entry,
&turn_cx,
)
.await?;
let AgenticLoopOutcome {
@@ -1146,6 +1158,7 @@ impl InsightChatService {
req: ChatTurnRequest,
normalized: String,
entry: Arc<TurnEntry>,
turn_span: &mut opentelemetry::global::BoxedSpan,
) -> Result<()> {
let active_persona = req
.persona_id
@@ -1171,11 +1184,21 @@ impl InsightChatService {
};
let backend = self.generator.resolve_backend(kind, &overrides).await?;
let model_used = backend.model().to_string();
// llm.* attributes on the turn-level span (issue #130).
turn_span.set_attribute(KeyValue::new("llm.backend", kind.as_str()));
turn_span.set_attribute(KeyValue::new("llm.model", model_used.clone()));
let turn_cx = opentelemetry::Context::new()
.with_remote_span_context(turn_span.span_context().clone());
let image_base64: Option<String> = self.generator.load_image_as_base64(&normalized).ok();
let exif = self.generator.fetch_exif(&normalized);
let date_taken_str = resolve_date_taken_for_context(&exif, &normalized);
let date_taken_str = resolve_date_taken_for_context(
&exif,
&normalized,
self.generator.display_zone(),
crate::camera_tz::server_zone(),
);
let gps = exif
.as_ref()
.and_then(|e| match (e.gps_latitude, e.gps_longitude) {
@@ -1185,7 +1208,7 @@ impl InsightChatService {
let visual_block = if !backend.images_inline {
match image_base64.as_deref() {
Some(b64) => match backend.local().describe_image(b64).await {
Some(b64) => match backend.local().describe_image(&turn_cx, b64).await {
Ok(desc) => {
format!("Visual description (from local vision model):\n{}\n", desc)
}
@@ -1240,6 +1263,7 @@ impl InsightChatService {
&active_persona,
max_iterations,
&entry,
&turn_cx,
)
.await?;
let AgenticLoopOutcome {
@@ -1328,6 +1352,7 @@ impl InsightChatService {
active_persona: &str,
max_iterations: usize,
entry: &Arc<TurnEntry>,
cx: &opentelemetry::Context,
) -> Result<AgenticLoopOutcome> {
crate::ai::insight_chat::run_streaming_agentic_loop_with_entry(
&self.generator,
@@ -1340,6 +1365,7 @@ impl InsightChatService {
active_persona,
max_iterations,
entry,
cx,
)
.await
}
@@ -1347,6 +1373,7 @@ impl InsightChatService {
async fn run_streaming_turn(
self: Arc<Self>,
req: ChatTurnRequest,
cx: opentelemetry::Context,
tx: tokio::sync::mpsc::Sender<ChatStreamEvent>,
) -> Result<()> {
if req.user_message.trim().is_empty() {
@@ -1376,17 +1403,17 @@ impl InsightChatService {
// library bootstraps cleanly, even when another library has
// an insight for the same rel_path.
let existing_insight = {
let cx = opentelemetry::Context::new();
let db_cx = opentelemetry::Context::new();
let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao");
dao.get_current_insight_for_library(&cx, req.library_id, &normalized)
dao.get_current_insight_for_library(&db_cx, req.library_id, &normalized)
.map_err(|e| anyhow!("failed to load insight: {:?}", e))?
};
if req.regenerate || existing_insight.is_none() {
return self.run_bootstrap_streaming(req, normalized, tx).await;
return self.run_bootstrap_streaming(req, normalized, cx, tx).await;
}
let insight = existing_insight.expect("just checked Some above");
self.run_continuation_streaming(req, normalized, insight, tx)
self.run_continuation_streaming(req, normalized, cx, insight, tx)
.await
}
@@ -1398,6 +1425,7 @@ impl InsightChatService {
&self,
req: ChatTurnRequest,
normalized: String,
cx: opentelemetry::Context,
insight: crate::database::models::PhotoInsight,
tx: tokio::sync::mpsc::Sender<ChatStreamEvent>,
) -> Result<()> {
@@ -1495,6 +1523,7 @@ impl InsightChatService {
&active_persona,
max_iterations,
&tx,
&cx,
)
.await?;
let AgenticLoopOutcome {
@@ -1606,6 +1635,7 @@ impl InsightChatService {
&self,
req: ChatTurnRequest,
normalized: String,
cx: opentelemetry::Context,
tx: tokio::sync::mpsc::Sender<ChatStreamEvent>,
) -> Result<()> {
let active_persona = req
@@ -1644,7 +1674,12 @@ impl InsightChatService {
// would otherwise default to today's date and miss every
// historical photo).
let exif = self.generator.fetch_exif(&normalized);
let date_taken_str = resolve_date_taken_for_context(&exif, &normalized);
let date_taken_str = resolve_date_taken_for_context(
&exif,
&normalized,
self.generator.display_zone(),
crate::camera_tz::server_zone(),
);
let gps = exif
.as_ref()
.and_then(|e| match (e.gps_latitude, e.gps_longitude) {
@@ -1657,7 +1692,7 @@ impl InsightChatService {
// images_inline backends send images directly to the chat model.
let visual_block = if !backend.images_inline {
match image_base64.as_deref() {
Some(b64) => match backend.local().describe_image(b64).await {
Some(b64) => match backend.local().describe_image(&cx, b64).await {
Ok(desc) => {
format!("Visual description (from local vision model):\n{}\n", desc)
}
@@ -1724,6 +1759,7 @@ impl InsightChatService {
&active_persona,
max_iterations,
&tx,
&cx,
)
.await?;
let AgenticLoopOutcome {
@@ -1806,6 +1842,7 @@ impl InsightChatService {
active_persona: &str,
max_iterations: usize,
tx: &tokio::sync::mpsc::Sender<ChatStreamEvent>,
cx: &opentelemetry::Context,
) -> Result<AgenticLoopOutcome> {
let mut tool_calls_made = 0usize;
let mut iterations_used = 0usize;
@@ -1824,7 +1861,7 @@ impl InsightChatService {
let mut stream = backend
.chat()
.chat_with_tools_stream(messages.clone(), tools.clone())
.chat_with_tools_stream(cx, messages.clone(), tools.clone())
.await?;
let mut final_message: Option<ChatMessage> = None;
@@ -1918,7 +1955,7 @@ impl InsightChatService {
let synthetic_idx = push_synthetic_final_prompt(messages);
let mut stream = backend
.chat()
.chat_with_tools_stream(messages.clone(), vec![])
.chat_with_tools_stream(cx, messages.clone(), vec![])
.await?;
let mut final_message: Option<ChatMessage> = None;
while let Some(ev) = stream.next().await {
@@ -2020,24 +2057,33 @@ fn build_bootstrap_system_message(
}
/// Resolve a human-readable `YYYY-MM-DD` date string for the photo
/// context block. Waterfall: EXIF `date_taken` → filename pattern →
/// `None`. The fs-time fallback that `generate_agentic_insight_for_photo`
/// context block (ADR-0001). Waterfall: the row's display wall (new
/// contract when converted, per-source convention while unconverted) →
/// filename pattern (wall patterns as-is, unix-timestamp patterns in the
/// display zone) → `None`. The fs-time fallback that insight generation
/// uses is intentionally NOT applied here — for chat we'd rather show
/// "unknown" than a misleading inode mtime as the photo's date.
fn resolve_date_taken_for_context(
exif: &Option<crate::database::models::ImageExif>,
file_path: &str,
display_zone: chrono_tz::Tz,
server_zone: Option<chrono_tz::Tz>,
) -> Option<String> {
let from_exif = exif
.as_ref()
.and_then(|e| e.date_taken)
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
.map(|dt| dt.format("%Y-%m-%d").to_string());
if from_exif.is_some() {
return from_exif;
if let Some(e) = exif
&& let Some(wall) = crate::date_view::row_display_wall(e, display_zone, server_zone)
{
return Some(wall.format("%Y-%m-%d").to_string());
}
crate::memories::extract_date_from_filename(file_path)
.map(|fd| fd.legacy_datetime().format("%Y-%m-%d").to_string())
crate::memories::extract_date_from_filename(file_path).and_then(|fd| {
let wall = match &fd {
crate::memories::FilenameDate::Wall(w) => *w,
crate::memories::FilenameDate::Utc(dt) => display_zone
.timestamp_opt(dt.timestamp(), 0)
.single()?
.naive_local(),
};
Some(wall.format("%Y-%m-%d").to_string())
})
}
/// Validate a stored→effective backend transition for a chat continuation.
@@ -2289,6 +2335,7 @@ pub async fn run_streaming_agentic_loop_with_entry(
active_persona: &str,
max_iterations: usize,
entry: &Arc<TurnEntry>,
cx: &opentelemetry::Context,
) -> Result<AgenticLoopOutcome> {
let mut tool_calls_made = 0usize;
let mut iterations_used = 0usize;
@@ -2318,7 +2365,7 @@ pub async fn run_streaming_agentic_loop_with_entry(
let mut stream = backend
.chat()
.chat_with_tools_stream(messages.clone(), tools.clone())
.chat_with_tools_stream(cx, messages.clone(), tools.clone())
.await?;
let mut final_message: Option<ChatMessage> = None;
@@ -2401,7 +2448,7 @@ pub async fn run_streaming_agentic_loop_with_entry(
let synthetic_idx = push_synthetic_final_prompt(messages);
let mut stream = backend
.chat()
.chat_with_tools_stream(messages.clone(), vec![])
.chat_with_tools_stream(cx, messages.clone(), vec![])
.await?;
let mut final_message: Option<ChatMessage> = None;
while let Some(ev) = stream.next().await {
@@ -3385,7 +3432,12 @@ mod tests {
original_camera_offset_minutes: None,
original_date_taken_tz_source: None,
});
let out = resolve_date_taken_for_context(&exif, "Screenshot_2014-06-01.png");
let out = resolve_date_taken_for_context(
&exif,
"Screenshot_2014-06-01.png",
chrono_tz::Tz::America__New_York,
Some(chrono_tz::Tz::America__New_York),
);
assert_eq!(out.as_deref(), Some("2021-08-15"));
}
@@ -3394,13 +3446,157 @@ mod tests {
// memories::extract_date_from_filename requires date+time in
// the filename — date-only patterns aren't matched. Use the
// canonical screenshot pattern for the regression case.
let out = resolve_date_taken_for_context(&None, "Screenshot_2014-06-01-20-44-50.png");
let out = resolve_date_taken_for_context(
&None,
"Screenshot_2014-06-01-20-44-50.png",
chrono_tz::Tz::America__New_York,
Some(chrono_tz::Tz::America__New_York),
);
assert_eq!(out.as_deref(), Some("2014-06-01"));
}
#[test]
fn date_taken_for_context_returns_none_when_neither_source() {
let out = resolve_date_taken_for_context(&None, "DSC_5171.JPG");
let out = resolve_date_taken_for_context(
&None,
"DSC_5171.JPG",
chrono_tz::Tz::America__New_York,
Some(chrono_tz::Tz::America__New_York),
);
assert!(out.is_none());
}
/// AC-4 / AC-5: a Japan-offset (+9 h) photo anchored at local 21:00.
/// The AI is told the camera wall clock (instant + offset — a midnight
/// rollover relative to the owner's display zone is expected and
/// correct), while the SMS/location/calendar windows anchor on the
/// true instant with zero offset math.
#[test]
fn japan_offset_row_ai_date_is_camera_wall_and_anchor_unshifted() {
use chrono::NaiveDate;
// 2024-07-05 23:30 UTC = 2024-07-06 08:30 JST = 2024-07-05 19:30 EDT.
let instant = NaiveDate::from_ymd_opt(2024, 7, 5)
.unwrap()
.and_hms_opt(23, 30, 0)
.unwrap()
.and_utc();
let date_utc = instant.timestamp();
let row = crate::database::models::ImageExif {
id: 0,
library_id: 1,
file_path: "tokyo.jpg".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: Some(date_utc),
created_time: 0,
last_modified: 0,
content_hash: None,
size_bytes: None,
phash_64: None,
dhash_64: None,
duplicate_of_hash: None,
duplicate_decided_at: None,
date_taken_source: Some("exif".to_string()),
original_date_taken: None,
original_date_taken_source: None,
clip_embedding: None,
clip_model_version: None,
date_utc: Some(date_utc),
camera_offset_minutes: Some(540),
date_taken_tz_source: Some("offset_tag".to_string()),
original_date_utc: None,
original_camera_offset_minutes: None,
original_date_taken_tz_source: None,
};
let ny = chrono_tz::Tz::America__New_York;
// The prompt/UI date is the camera wall: 2024-07-06 08:30 JST —
// one day AFTER the owner's display-zone date (midnight rollover
// is correct here, the AI must see what the camera saw).
let ai_date = resolve_date_taken_for_context(&Some(row.clone()), "tokyo.jpg", ny, Some(ny));
assert_eq!(ai_date.as_deref(), Some("2024-07-06"));
// The proximity windows (SMS / location / calendar) anchor on the
// true instant — no +9 h, no -4 h, nothing.
let anchor = crate::date_view::row_true_instant(&row, ny, Some(ny));
assert_eq!(anchor, Some(date_utc));
}
/// AC-6: an unconverted container video row — the AI context date is
/// the display-zone rendering of the true instant (the raw UTC creation
/// time), matching the metadata/UI date. No 4–5 h offset, no midnight
/// rollover relative to the UTC-naive reading.
#[test]
fn unconverted_video_row_ai_date_matches_display_wall() {
use chrono::NaiveDate;
// The container's raw creation string is "2024-07-06 01:30:00"
// (a true UTC instant). The old server parsed that naive string
// as server-local (EDT) → legacy epoch = 05:30 UTC. The AI must
// be told the owner's evening — July 5 — not July 6 (the UTC
// date the old code would have rendered).
let legacy = NaiveDate::from_ymd_opt(2024, 7, 6)
.unwrap()
.and_hms_opt(5, 30, 0)
.unwrap()
.and_utc()
.timestamp();
let row = Some(crate::database::models::ImageExif {
id: 0,
library_id: 1,
file_path: "movie.mp4".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: Some(legacy),
created_time: 0,
last_modified: 0,
content_hash: None,
size_bytes: None,
phash_64: None,
dhash_64: None,
duplicate_of_hash: None,
duplicate_decided_at: None,
date_taken_source: Some("exiftool".to_string()),
original_date_taken: None,
original_date_taken_source: None,
clip_embedding: None,
clip_model_version: None,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
original_date_utc: None,
original_camera_offset_minutes: None,
original_date_taken_tz_source: None,
});
let ny = chrono_tz::Tz::America__New_York;
let ai_date = resolve_date_taken_for_context(&row, "movie.mp4", ny, Some(ny));
assert_eq!(ai_date.as_deref(), Some("2024-07-05"));
}
}
+153 -95
View File
@@ -1,12 +1,13 @@
use anyhow::Result;
use base64::Engine as _;
use chrono::{DateTime, Local, NaiveDate, Utc};
use chrono::{DateTime, Local, NaiveDate, TimeZone, Utc};
use image::ImageFormat;
use opentelemetry::KeyValue;
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
use serde::Deserialize;
use std::fs::File;
use std::io::Cursor;
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use crate::ai::apollo_client::{ApolloClient, ApolloPlace};
@@ -23,7 +24,7 @@ use crate::database::{
LocationHistoryDao, SearchHistoryDao,
};
use crate::libraries::Library;
use crate::memories::{FilenameDate, extract_date_from_filename};
use crate::memories::extract_date_from_filename;
use crate::otel::global_tracer;
use crate::tags::TagDao;
use crate::utils::{earliest_fs_time, normalize_path, retry_with_backoff, retry_with_backoff_if};
@@ -161,6 +162,10 @@ pub struct InsightGenerator {
// the per-persona "reviewed-only" strict-mode toggle).
persona_dao: Arc<Mutex<Box<dyn crate::database::PersonaDao>>>,
/// Owner display-zone prefs (ADR-0001): prompt dates and the chat
/// context block render in this zone.
user_ai_prefs_dao: Arc<Mutex<Box<dyn crate::database::UserAiPrefsDao>>>,
libraries: Vec<Library>,
}
@@ -201,6 +206,7 @@ impl InsightGenerator {
face_dao: Arc<Mutex<Box<dyn crate::faces::FaceDao>>>,
knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>>,
persona_dao: Arc<Mutex<Box<dyn crate::database::PersonaDao>>>,
user_ai_prefs_dao: Arc<Mutex<Box<dyn crate::database::UserAiPrefsDao>>>,
libraries: Vec<Library>,
) -> Self {
Self {
@@ -219,10 +225,86 @@ impl InsightGenerator {
face_dao,
knowledge_dao,
persona_dao,
user_ai_prefs_dao,
libraries,
}
}
/// The owner's display zone (ADR-0001): prefs `display_tz` when set and
/// valid, the app default otherwise. Read per call so a pref change
/// takes effect without a restart; a DB error degrades to the default.
pub fn display_zone(&self) -> chrono_tz::Tz {
let name = {
let stored = self
.user_ai_prefs_dao
.lock()
.ok()
.and_then(|mut dao| dao.get_prefs(&opentelemetry::Context::new()).ok().flatten())
.and_then(|p| p.display_tz);
crate::camera_tz::display_zone_for(stored.as_deref())
};
chrono_tz::Tz::from_str(&name).unwrap_or(chrono_tz::Tz::America__New_York)
}
/// The photo's date anchor pair (ADR-0001), shared by the non-agentic
/// and agentic insight paths:
/// - the **true instant** — the row's new-contract instant when present,
/// the legacy value under its per-source convention while unconverted,
/// then filename / fs-time / now — used as the zero-shift anchor for
/// the SMS / location / calendar proximity windows;
/// - the **prompt-facing display wall** — the row's display wall (camera
/// wall when the row carries an offset, display-zone rendering
/// otherwise) — the date the AI is told, matching what the owner sees.
fn resolve_date_anchor(
&self,
exif: &Option<crate::database::models::ImageExif>,
file_path: &str,
log_label: &str,
) -> (i64, NaiveDate) {
let display_zone = self.display_zone();
let server_zone = crate::camera_tz::server_zone();
let timestamp = exif
.as_ref()
.and_then(|e| crate::date_view::row_true_instant(e, display_zone, server_zone))
.unwrap_or_else(|| {
log::warn!("No date in EXIF for {}, trying filename", file_path);
extract_date_from_filename(file_path)
.and_then(|fd| crate::date_view::filename_true_instant(&fd, server_zone))
.or_else(|| {
let full_path = self.resolve_full_path(file_path)?;
File::open(&full_path)
.and_then(|f| f.metadata())
.inspect_err(|e| {
log::warn!(
"Failed to get file timestamp for {} {}: {}",
log_label,
file_path,
e
)
})
.ok()
.and_then(|m| earliest_fs_time(&m))
.map(|t| DateTime::<Utc>::from(t).timestamp())
})
.unwrap_or_else(|| Utc::now().timestamp())
});
let date_taken = exif
.as_ref()
.and_then(|e| crate::date_view::row_display_wall(e, display_zone, server_zone))
.map(|w| w.date())
.or_else(|| {
display_zone
.timestamp_opt(timestamp, 0)
.single()
.map(|dt| dt.date_naive())
})
.unwrap_or_else(|| Utc::now().date_naive());
(timestamp, date_taken)
}
/// Accessor for the insight DAO (used by async job completion to
/// look up the stored insight id).
pub fn insight_dao(&self) -> &Arc<Mutex<Box<dyn InsightDao>>> {
@@ -564,7 +646,8 @@ impl InsightGenerator {
// (`LLM_BACKEND` switch). Must match the backend that populated the
// daily-summary embeddings or similarity search will be garbage.
let query_embedding =
crate::ai::embed_query(&self.ollama, self.llamacpp.as_deref(), &query).await?;
crate::ai::embed_query(&search_cx, &self.ollama, self.llamacpp.as_deref(), &query)
.await?;
// Search for similar daily summaries with time-based weighting
// This prioritizes summaries temporally close to the query date
@@ -630,7 +713,8 @@ impl InsightGenerator {
// Must use the same backend that populated the daily-summary
// embeddings or similarity search is garbage (see embed_one docs).
let query_embedding =
crate::ai::embed_query(&self.ollama, self.llamacpp.as_deref(), query).await?;
crate::ai::embed_query(&search_cx, &self.ollama, self.llamacpp.as_deref(), query)
.await?;
let mut summary_dao = self
.daily_summary_dao
@@ -716,7 +800,9 @@ impl InsightGenerator {
let calendar_cx = parent_cx.with_span(span);
let query_embedding = if let Some(loc) = location {
match crate::ai::embed_query(&self.ollama, self.llamacpp.as_deref(), loc).await {
match crate::ai::embed_query(&calendar_cx, &self.ollama, self.llamacpp.as_deref(), loc)
.await
{
Ok(emb) => Some(emb),
Err(e) => {
log::warn!("Failed to generate embedding for location '{}': {}", loc, e);
@@ -887,18 +973,23 @@ impl InsightGenerator {
)
};
let query_embedding =
match crate::ai::embed_query(&self.ollama, self.llamacpp.as_deref(), &query_text).await
{
Ok(emb) => emb,
Err(e) => {
log::warn!("Failed to generate search embedding: {}", e);
search_cx.span().set_status(Status::Error {
description: e.to_string().into(),
});
return Ok(None);
}
};
let query_embedding = match crate::ai::embed_query(
&search_cx,
&self.ollama,
self.llamacpp.as_deref(),
&query_text,
)
.await
{
Ok(emb) => emb,
Err(e) => {
log::warn!("Failed to generate search embedding: {}", e);
search_cx.span().set_status(Status::Error {
description: e.to_string().into(),
});
return Ok(None);
}
};
let searches = {
let mut dao = self
@@ -1055,35 +1146,8 @@ impl InsightGenerator {
.map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))?
};
// Get full timestamp for proximity-based message filtering
let timestamp = if let Some(ts) = exif.as_ref().and_then(|e| e.date_taken) {
ts
} else {
log::warn!("No date_taken in EXIF for {}, trying filename", file_path);
extract_date_from_filename(&file_path)
.map(FilenameDate::legacy_timestamp)
.or_else(|| {
let full_path = self.resolve_full_path(&file_path)?;
File::open(&full_path)
.and_then(|f| f.metadata())
.inspect_err(|e| {
log::warn!(
"Failed to get file timestamp for insight {}: {}",
file_path,
e
)
})
.ok()
.and_then(|m| earliest_fs_time(&m))
.map(|t| DateTime::<Utc>::from(t).timestamp())
})
.unwrap_or_else(|| Utc::now().timestamp())
};
let date_taken = DateTime::from_timestamp(timestamp, 0)
.map(|dt| dt.date_naive())
.unwrap_or_else(|| Utc::now().date_naive());
// True-instant anchor + prompt-facing display wall (ADR-0001).
let (timestamp, date_taken) = self.resolve_date_anchor(&exif, &file_path, "insight");
// 3. Extract contact name from file path
let contact = Self::extract_contact_from_path(&file_path);
@@ -1199,7 +1263,10 @@ impl InsightGenerator {
// Generate brief photo description for RAG enrichment (vision models only)
let photo_description: Option<String> = if let Some(ref img_b64) = image_base64 {
match ollama_client.generate_photo_description(img_b64).await {
match ollama_client
.generate_photo_description(&insight_cx, img_b64)
.await
{
Ok(desc) => {
log::info!("Photo description for RAG enrichment: {}", desc);
Some(desc)
@@ -1268,7 +1335,7 @@ impl InsightGenerator {
if !immediate_messages.is_empty() {
// Step 2: Extract topics from immediate messages to enrich RAG query
let topics = self
.extract_topics_from_messages(&immediate_messages, &ollama_client)
.extract_topics_from_messages(&immediate_messages, &ollama_client, &insight_cx)
.await;
log::info!("Extracted topics for query enrichment: {:?}", topics);
@@ -1324,6 +1391,7 @@ impl InsightGenerator {
&immediate_messages,
&ollama_client,
custom_system_prompt.as_deref(),
&insight_cx,
)
.await
.unwrap_or_else(|| String::from("No immediate context"));
@@ -1333,6 +1401,7 @@ impl InsightGenerator {
&historical_messages,
&ollama_client,
custom_system_prompt.as_deref(),
&insight_cx,
)
.await
.unwrap_or_else(|| String::from("No historical context"));
@@ -1351,6 +1420,7 @@ impl InsightGenerator {
&immediate_messages,
&ollama_client,
custom_system_prompt.as_deref(),
&insight_cx,
)
.await;
}
@@ -1361,6 +1431,7 @@ impl InsightGenerator {
&immediate_messages,
&ollama_client,
custom_system_prompt.as_deref(),
&insight_cx,
)
.await;
}
@@ -1386,6 +1457,7 @@ impl InsightGenerator {
&rag_messages,
&ollama_client,
custom_system_prompt.as_deref(),
&insight_cx,
)
.await;
}
@@ -1418,7 +1490,7 @@ impl InsightGenerator {
if !sms_messages.is_empty() {
match self
.sms_client
.summarize_context(&sms_messages, &ollama_client)
.summarize_context(&sms_messages, &ollama_client, &insight_cx)
.await
{
Ok(summary) => {
@@ -1502,6 +1574,7 @@ impl InsightGenerator {
// 10. Generate summary first, then derive title from the summary
let summary = ollama_client
.generate_photo_summary(
&insight_cx,
date_taken,
location.as_deref(),
contact.as_deref(),
@@ -1512,7 +1585,7 @@ impl InsightGenerator {
.await?;
let title = ollama_client
.generate_photo_title(&summary, custom_system_prompt.as_deref())
.generate_photo_title(&insight_cx, &summary, custom_system_prompt.as_deref())
.await?;
log::info!("Generated title: {}", title);
@@ -1579,6 +1652,7 @@ impl InsightGenerator {
&self,
messages: &[crate::ai::SmsMessage],
ollama: &OllamaClient,
cx: &opentelemetry::Context,
) -> Vec<String> {
if messages.is_empty() {
return Vec::new();
@@ -1614,7 +1688,7 @@ Return ONLY the comma-separated list, nothing else."#,
);
match ollama
.generate(&prompt, Some("You are an entity extraction assistant. Extract proper nouns, people, places, and domain-specific terms that provide context."))
.generate(cx, &prompt, Some("You are an entity extraction assistant. Extract proper nouns, people, places, and domain-specific terms that provide context."))
.await
{
Ok(response) => {
@@ -1649,6 +1723,7 @@ Return ONLY the comma-separated list, nothing else."#,
messages: &[String],
ollama: &OllamaClient,
custom_system: Option<&str>,
cx: &opentelemetry::Context,
) -> Option<String> {
if messages.is_empty() {
return None;
@@ -1669,7 +1744,7 @@ Return ONLY the summary, nothing else."#,
let system = custom_system
.unwrap_or("You are a context summarization assistant. Be concise and factual.");
match ollama.generate(&prompt, Some(system)).await {
match ollama.generate(cx, &prompt, Some(system)).await {
Ok(summary) => Some(summary),
Err(e) => {
log::warn!("Failed to summarize messages: {}", e);
@@ -1685,6 +1760,7 @@ Return ONLY the summary, nothing else."#,
messages: &[crate::ai::SmsMessage],
ollama: &OllamaClient,
custom_system: Option<&str>,
cx: &opentelemetry::Context,
) -> Option<String> {
if messages.is_empty() {
return None;
@@ -1730,7 +1806,7 @@ Return ONLY the summary, nothing else."#,
"You are a context summarization assistant. Be detailed and factual, preserving important context.",
);
match ollama.generate(&prompt, Some(system)).await {
match ollama.generate(cx, &prompt, Some(system)).await {
Ok(summary) => Some(summary),
Err(e) => {
log::warn!("Failed to summarize immediate context: {}", e);
@@ -1774,7 +1850,7 @@ Return ONLY the summary, nothing else."#,
"get_file_tags" => self.tool_get_file_tags(arguments, cx).await,
"get_faces_in_photo" => self.tool_get_faces_in_photo(arguments, cx).await,
"describe_photo" => {
self.tool_describe_photo(backend.local(), image_base64)
self.tool_describe_photo(backend.local(), image_base64, cx)
.await
}
"reverse_geocode" => self.tool_reverse_geocode(arguments).await,
@@ -1825,7 +1901,7 @@ Return ONLY the summary, nothing else."#,
&self,
args: &serde_json::Value,
local: &dyn LlmClient,
_cx: &opentelemetry::Context,
cx: &opentelemetry::Context,
) -> String {
let query = match args.get("query").and_then(|v| v.as_str()) {
Some(q) => q.to_string(),
@@ -1889,7 +1965,10 @@ Return ONLY the summary, nothing else."#,
};
let final_results = if rerank_enabled && results.len() > limit {
match self.rerank_with_llm(&query, &results, limit, local).await {
match self
.rerank_with_llm(cx, &query, &results, limit, local)
.await
{
Ok(reordered) => reordered,
Err(e) => {
log::warn!("rerank failed, using vector order: {}", e);
@@ -1912,6 +1991,7 @@ Return ONLY the summary, nothing else."#,
/// tool_result. On parse failure we fall back to the input order.
async fn rerank_with_llm(
&self,
cx: &opentelemetry::Context,
query: &str,
candidates: &[String],
limit: usize,
@@ -1955,7 +2035,7 @@ Return ONLY the summary, nothing else."#,
let started = std::time::Instant::now();
let system =
Some("You are a terse relevance ranker. You output only numbers separated by commas.");
let response = local.generate(&prompt, system, None).await?;
let response = local.generate(cx, &prompt, system, None).await?;
log::info!(
"rerank: finished in {} ms (prompt={} chars)",
started.elapsed().as_millis(),
@@ -2565,10 +2645,11 @@ Return ONLY the summary, nothing else."#,
&self,
local: &dyn LlmClient,
image_base64: &Option<String>,
cx: &opentelemetry::Context,
) -> String {
log::info!("tool_describe_photo: generating visual description");
match image_base64 {
Some(img) => match local.describe_image(img).await {
Some(img) => match local.describe_image(cx, img).await {
Ok(desc) => desc,
Err(e) => format!("Error describing photo: {}", e),
},
@@ -2976,6 +3057,7 @@ Return ONLY the summary, nothing else."#,
// configured local backend.
let embed_text = format!("{} {}", name, description);
let embedding: Option<Vec<u8>> = match crate::ai::embed_document(
cx,
&self.ollama,
self.llamacpp.as_deref(),
&embed_text,
@@ -4147,34 +4229,9 @@ Return ONLY the summary, nothing else."#,
.map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))?
};
// 4. Extract timestamp and contact
let timestamp = if let Some(ts) = exif.as_ref().and_then(|e| e.date_taken) {
ts
} else {
log::warn!("No date_taken in EXIF for {}, trying filename", file_path);
extract_date_from_filename(&file_path)
.map(FilenameDate::legacy_timestamp)
.or_else(|| {
let full_path = self.resolve_full_path(&file_path)?;
File::open(&full_path)
.and_then(|f| f.metadata())
.inspect_err(|e| {
log::warn!(
"Failed to get file timestamp for agentic insight {}: {}",
file_path,
e
)
})
.ok()
.and_then(|m| earliest_fs_time(&m))
.map(|t| DateTime::<Utc>::from(t).timestamp())
})
.unwrap_or_else(|| Utc::now().timestamp())
};
let date_taken = DateTime::from_timestamp(timestamp, 0)
.map(|dt| dt.date_naive())
.unwrap_or_else(|| Utc::now().date_naive());
// 4. Extract date and contact. The agentic prompt only needs the
// display wall (ADR-0001) — its proximity windows are tool-driven.
let (_, date_taken) = self.resolve_date_anchor(&exif, &file_path, "agentic insight");
let contact = Self::extract_contact_from_path(&file_path);
log::info!("Agentic: date_taken={}, contact={:?}", date_taken, contact);
@@ -4247,7 +4304,7 @@ Return ONLY the summary, nothing else."#,
// the local backend so non-text work stays off OpenRouter.
let inlined_visual_description: Option<String> = if !backend.images_inline {
match image_base64.as_deref() {
Some(b64) => match backend.local().describe_image(b64).await {
Some(b64) => match backend.local().describe_image(&insight_cx, b64).await {
Ok(desc) => {
log::info!("{}: vision describe succeeded ({} chars)", kind, desc.len());
Some(desc)
@@ -4367,7 +4424,7 @@ Return ONLY the summary, nothing else."#,
let (response, prompt_tokens, eval_tokens) = backend
.chat()
.chat_with_tools(messages.clone(), tools.clone())
.chat_with_tools(&loop_cx, messages.clone(), tools.clone())
.await?;
last_prompt_eval_count = prompt_tokens;
@@ -4436,7 +4493,7 @@ Return ONLY the summary, nothing else."#,
)));
let (final_response, prompt_tokens, eval_tokens) = backend
.chat()
.chat_with_tools(messages.clone(), tools.clone())
.chat_with_tools(&loop_cx, messages.clone(), tools.clone())
.await?;
last_prompt_eval_count = prompt_tokens;
last_eval_count = eval_tokens;
@@ -4469,7 +4526,7 @@ Return ONLY the summary, nothing else."#,
}
let (resp2, pt2, et2) = backend
.chat()
.chat_with_tools(messages.clone(), vec![])
.chat_with_tools(&loop_cx, messages.clone(), vec![])
.await?;
last_prompt_eval_count = pt2;
last_eval_count = et2;
@@ -4607,6 +4664,7 @@ Return ONLY the summary, nothing else."#,
tools: Vec<Tool>,
max_iter: usize,
) -> Result<String> {
let loop_cx = opentelemetry::Context::current();
let mut final_content = String::new();
for iteration in 0..max_iter {
@@ -4614,7 +4672,7 @@ Return ONLY the summary, nothing else."#,
let (response, _prompt_tokens, _eval_tokens) = backend
.chat()
.chat_with_tools(messages.clone(), tools.clone())
.chat_with_tools(&loop_cx, messages.clone(), tools.clone())
.await?;
// Sanitize tool call arguments before pushing back into history.
@@ -4656,7 +4714,7 @@ Return ONLY the summary, nothing else."#,
"",
0,
"",
&opentelemetry::Context::new(),
&loop_cx,
)
.await;
messages.push(ChatMessage::tool_result(result));
@@ -4680,7 +4738,7 @@ Return ONLY the summary, nothing else."#,
));
let (final_response, _, _) = backend
.chat()
.chat_with_tools(messages.clone(), tools.clone())
.chat_with_tools(&loop_cx, messages.clone(), tools.clone())
.await?;
// Same bounded tool round as the insight loop: execute any last
// tool call, then force a tool-less turn for the actual answer.
@@ -4698,14 +4756,14 @@ Return ONLY the summary, nothing else."#,
"",
0,
"",
&opentelemetry::Context::new(),
&loop_cx,
)
.await;
messages.push(ChatMessage::tool_result(result));
}
let (resp2, _, _) = backend
.chat()
.chat_with_tools(messages.clone(), vec![])
.chat_with_tools(&loop_cx, messages.clone(), vec![])
.await?;
final_content = resp2.content;
} else {
+111 -26
View File
@@ -20,8 +20,11 @@
// insight_generator / insight_chat.
#![allow(dead_code)]
use anyhow::{Context, Result, anyhow, bail};
use anyhow::Context as _;
use anyhow::{Result, anyhow, bail};
use async_trait::async_trait;
use opentelemetry::trace::{Span, Status};
use opentelemetry::{Context, KeyValue};
use reqwest::Client;
use serde::Deserialize;
use serde_json::{Value, json};
@@ -486,6 +489,7 @@ impl LlamaCppClient {
async fn chat_completion_with_model(
&self,
model: &str,
mut span: Option<&mut opentelemetry::global::BoxedSpan>,
messages: Vec<ChatMessage>,
tools: Vec<Tool>,
) -> Result<(ChatMessage, Option<i32>, Option<i32>)> {
@@ -517,19 +521,31 @@ impl LlamaCppClient {
// Wait for any TTS synthesis to release the GPU before the request
// timeout starts (see ai::gpu).
let _gpu = crate::ai::gpu::llm_lease().await;
let resp = self
.client
.post(&url)
.json(&Value::Object(body))
.send()
.await
.with_context(|| format!("POST {} failed", url))?;
let mut request = self.client.post(&url).json(&Value::Object(body));
if let Some(s) = span.as_mut() {
request = crate::otel::with_traceparent(request, s.span_context());
}
let resp = match request.send().await {
Ok(r) => r,
Err(e) => {
if let Some(s) = span.as_mut() {
s.set_status(Status::error("llama-swap request failed"));
}
return Err(e).with_context(|| format!("POST {} failed", url));
}
};
if !resp.status().is_success() {
let status = resp.status();
crate::otel::record_http_failure(
span.as_deref_mut(),
status.as_u16(),
&format!("llama-swap chat request failed: {status}"),
);
let body = resp.text().await.unwrap_or_default();
bail!("llama-swap chat request failed: {} — {}", status, body);
}
crate::otel::set_http_status(span.as_deref_mut(), resp.status().as_u16());
let parsed: Value = resp.json().await.context("parsing chat response")?;
let choice = parsed
@@ -562,14 +578,35 @@ impl LlamaCppClient {
log_timings(&parsed, prompt_tokens, completion_tokens);
if let Some(s) = span.as_mut() {
if let Some(pt) = prompt_tokens {
s.set_attribute(KeyValue::new("llm.tokens.input", pt as i64));
}
if let Some(ct) = completion_tokens {
s.set_attribute(KeyValue::new("llm.tokens.output", ct as i64));
}
s.set_status(Status::Ok);
}
Ok((chat_msg, prompt_tokens, completion_tokens))
}
/// Start the `llm.request` span for an outgoing llama-swap call.
fn start_request_span(
&self,
cx: &Context,
operation: &str,
model: &str,
) -> opentelemetry::global::BoxedSpan {
crate::otel::start_llm_request_span(cx, "llamacpp", operation, model)
}
}
#[async_trait]
impl LlmClient for LlamaCppClient {
async fn generate(
&self,
cx: &Context,
prompt: &str,
system: Option<&str>,
images: Option<Vec<String>>,
@@ -582,12 +619,15 @@ impl LlmClient for LlamaCppClient {
user.images = images;
messages.push(user);
let (reply, _, _) = self.chat_with_tools(messages, Vec::new()).await?;
// Delegates to chat_with_tools, which starts the llm.request span
// (operation=chat) — one span per HTTP request.
let (reply, _, _) = self.chat_with_tools(cx, messages, Vec::new()).await?;
Ok(reply.content)
}
async fn chat_with_tools(
&self,
cx: &Context,
messages: Vec<ChatMessage>,
tools: Vec<Tool>,
) -> Result<(ChatMessage, Option<i32>, Option<i32>)> {
@@ -597,15 +637,25 @@ impl LlmClient for LlamaCppClient {
messages.len(),
tools.len()
);
self.chat_completion_with_model(&self.primary_model.clone(), messages, tools)
.await
let mut span = self.start_request_span(cx, "chat", &self.primary_model);
self.chat_completion_with_model(
&self.primary_model.clone(),
Some(&mut span),
messages,
tools,
)
.await
}
async fn chat_with_tools_stream(
&self,
cx: &Context,
messages: Vec<ChatMessage>,
tools: Vec<Tool>,
) -> Result<BoxStream<'static, Result<LlmStreamEvent>>> {
// The span outlives this method — it moves into the stream
// generator and is finalized when the response drains.
let mut span = self.start_request_span(cx, "chat_stream", &self.primary_model);
let url = format!("{}/chat/completions", self.base_url);
let roles: Vec<&str> = messages.iter().map(|m| m.role.as_str()).collect();
log::debug!(
@@ -639,19 +689,24 @@ impl LlmClient for LlamaCppClient {
// timeout starts (see ai::gpu). The guard is moved into the stream
// below so the lease spans the whole generation, not just the send.
let gpu = crate::ai::gpu::llm_lease().await;
let resp = self
.client
.post(&url)
.json(&Value::Object(body))
let mut request = self.client.post(&url).json(&Value::Object(body));
request = crate::otel::with_traceparent(request, span.span_context());
let resp = request
.send()
.await
.with_context(|| format!("POST {} failed", url))?;
if !resp.status().is_success() {
let status = resp.status();
crate::otel::record_http_failure(
Some(&mut span),
status.as_u16(),
&format!("llama-swap stream request failed: {status}"),
);
let body = resp.text().await.unwrap_or_default();
bail!("llama-swap stream request failed: {} — {}", status, body);
}
crate::otel::set_http_status(Some(&mut span), resp.status().as_u16());
let byte_stream = resp.bytes_stream();
let stream = async_stream::stream! {
@@ -760,6 +815,13 @@ impl LlmClient for LlamaCppClient {
tool_calls,
images: None,
};
if let Some(pt) = prompt_tokens {
span.set_attribute(KeyValue::new("llm.tokens.input", pt as i64));
}
if let Some(ct) = completion_tokens {
span.set_attribute(KeyValue::new("llm.tokens.output", ct as i64));
}
span.set_status(Status::Ok);
yield Ok(LlmStreamEvent::Done {
message,
prompt_eval_count: prompt_tokens,
@@ -770,7 +832,8 @@ impl LlmClient for LlamaCppClient {
Ok(Box::pin(stream))
}
async fn generate_embeddings(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
async fn generate_embeddings(&self, cx: &Context, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
let mut span = self.start_request_span(cx, "embeddings", &self.embedding_model);
// Deliberately NO GPU lease: the embed slot sits in llama-swap's
// always-resident group and never participates in a model swap, so
// leasing here would only stall searches behind a queued synthesis.
@@ -780,19 +843,27 @@ impl LlmClient for LlamaCppClient {
"input": texts,
});
let resp = self
.client
.post(&url)
.json(&body)
.send()
.await
.with_context(|| format!("POST {} failed", url))?;
let mut request = self.client.post(&url).json(&body);
request = crate::otel::with_traceparent(request, span.span_context());
let resp = match request.send().await {
Ok(r) => r,
Err(e) => {
span.set_status(Status::error("llama-swap embedding request failed"));
return Err(e).with_context(|| format!("POST {} failed", url));
}
};
if !resp.status().is_success() {
let status = resp.status();
crate::otel::record_http_failure(
Some(&mut span),
status.as_u16(),
&format!("llama-swap embedding request failed: {status}"),
);
let body = resp.text().await.unwrap_or_default();
bail!("llama-swap embedding request failed: {} — {}", status, body);
}
crate::otel::set_http_status(Some(&mut span), resp.status().as_u16());
#[derive(Deserialize)]
struct EmbedResponse {
@@ -803,11 +874,20 @@ impl LlmClient for LlamaCppClient {
embedding: Vec<f32>,
}
let parsed: EmbedResponse = resp.json().await.context("parsing embed response")?;
let parsed: EmbedResponse = match resp.json().await {
Ok(p) => p,
Err(e) => {
span.set_status(Status::error("parsing embed response failed"));
return Err(e).context("parsing embed response");
}
};
span.set_status(Status::Ok);
Ok(parsed.data.into_iter().map(|i| i.embedding).collect())
}
async fn describe_image(&self, image_base64: &str) -> Result<String> {
async fn describe_image(&self, cx: &Context, image_base64: &str) -> Result<String> {
// Own span with operation=describe_image — routes through the vision slot.
let mut span = self.start_request_span(cx, "describe_image", &self.vision_model);
let prompt = "Briefly describe what you see in this image in 1-2 sentences. \
Focus on the people, location, and activity.";
let system = "You are a scene description assistant. Be concise and factual.";
@@ -823,7 +903,12 @@ impl LlmClient for LlamaCppClient {
];
let (reply, _, _) = self
.chat_completion_with_model(&self.vision_model.clone(), messages, Vec::new())
.chat_completion_with_model(
&self.vision_model.clone(),
Some(&mut span),
messages,
Vec::new(),
)
.await?;
Ok(reply.content)
}
+12 -2
View File
@@ -1,6 +1,7 @@
use anyhow::Result;
use async_trait::async_trait;
use futures::stream::BoxStream;
use opentelemetry::Context;
use serde::{Deserialize, Serialize};
/// Provider-agnostic surface for LLM backends (Ollama, OpenRouter, …).
@@ -10,6 +11,12 @@ use serde::{Deserialize, Serialize};
/// when a provider requires it (OpenAI-compatible APIs do), and `images`
/// stays as base64 strings here and is rewritten into content-parts where
/// needed.
///
/// Every method that performs an HTTP call takes the caller's tracing
/// `Context` as its first parameter so the impl can start an `llm.request`
/// span parented under the caller's feature span — the codebase threads
/// contexts explicitly rather than relying on ambient `Context::current()`
/// at deep call sites.
// First consumer lands in a later PR (OpenRouter impl + hybrid mode routing).
#[allow(dead_code)]
#[async_trait]
@@ -18,6 +25,7 @@ pub trait LlmClient: Send + Sync {
/// base64 images (ignored by providers without vision support).
async fn generate(
&self,
cx: &Context,
prompt: &str,
system: Option<&str>,
images: Option<Vec<String>>,
@@ -27,6 +35,7 @@ pub trait LlmClient: Send + Sync {
/// (which may contain tool_calls) plus optional prompt/eval token counts.
async fn chat_with_tools(
&self,
cx: &Context,
messages: Vec<ChatMessage>,
tools: Vec<Tool>,
) -> Result<(ChatMessage, Option<i32>, Option<i32>)>;
@@ -39,16 +48,17 @@ pub trait LlmClient: Send + Sync {
/// as one `Done` event.
async fn chat_with_tools_stream(
&self,
cx: &Context,
messages: Vec<ChatMessage>,
tools: Vec<Tool>,
) -> Result<BoxStream<'static, Result<LlmStreamEvent>>>;
/// Batch embedding generation. Dimensionality is provider/model specific.
async fn generate_embeddings(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>>;
async fn generate_embeddings(&self, cx: &Context, texts: &[&str]) -> Result<Vec<Vec<f32>>>;
/// One-shot vision description of an image. Used to convert images into
/// plain text for the hybrid-mode conversation flow.
async fn describe_image(&self, image_base64: &str) -> Result<String>;
async fn describe_image(&self, cx: &Context, image_base64: &str) -> Result<String>;
/// Enumerate available models with their capabilities.
async fn list_models(&self) -> Result<Vec<ModelCapabilities>>;
+25 -4
View File
@@ -47,12 +47,24 @@ impl LocalLlm {
/// 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
super::embed_query(
&opentelemetry::Context::new(),
&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
super::embed_document(
&opentelemetry::Context::new(),
&self.ollama,
self.llamacpp.as_deref(),
text,
)
.await
}
/// Single-shot local text generation via the `LLM_BACKEND`-selected
@@ -60,14 +72,23 @@ impl LocalLlm {
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;
return <LlamaCppClient as LlmClient>::generate(
lc,
&opentelemetry::Context::new(),
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
self.ollama
.generate(&opentelemetry::Context::new(), prompt, system)
.await
}
/// Label identifying which backend + model produces embeddings right
+9 -5
View File
@@ -115,24 +115,26 @@ fn embed_prefix(key: &str) -> String {
/// `Instruct: <task>\nQuery: `. Must pair with the document prefix the
/// corpus was embedded with or similarity degrades.
pub async fn embed_query(
cx: &opentelemetry::Context,
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_one(cx, 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(
cx: &opentelemetry::Context,
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_one(cx, ollama, llamacpp, &prefixed).await
}
/// Embed a batch of strings via the configured local backend. Routes
@@ -140,30 +142,32 @@ pub async fn embed_document(
/// configured), else Ollama. See [`local_backend_is_llamacpp`] for the
/// rationale on consistency.
pub async fn embed_many(
cx: &opentelemetry::Context,
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;
return <LlamaCppClient as LlmClient>::generate_embeddings(lc, cx, 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
ollama.generate_embeddings(cx, texts).await
}
/// Embed one string via the configured local backend. Single-text
/// convenience over [`embed_many`].
pub async fn embed_one(
cx: &opentelemetry::Context,
ollama: &OllamaClient,
llamacpp: Option<&LlamaCppClient>,
text: &str,
) -> anyhow::Result<Vec<f32>> {
let mut vecs = embed_many(ollama, llamacpp, &[text]).await?;
let mut vecs = embed_many(cx, ollama, llamacpp, &[text]).await?;
vecs.pop()
.ok_or_else(|| anyhow::anyhow!("embedding backend returned no embeddings"))
}
+4 -1
View File
@@ -259,10 +259,13 @@ pub async fn translate_nl_query(
nl: &str,
tag_vocab: &[(i32, String)],
today: chrono::NaiveDate,
cx: &opentelemetry::Context,
) -> 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?;
let (msg, _, _) = client
.chat_with_tools(cx, messages, Vec::<Tool>::new())
.await?;
parse_response(&msg.content, tag_vocab)
}
+238 -55
View File
@@ -1,6 +1,9 @@
use anyhow::{Context, Result};
use anyhow::Context as _;
use anyhow::Result;
use async_trait::async_trait;
use chrono::NaiveDate;
use opentelemetry::trace::{Span, Status};
use opentelemetry::{Context, KeyValue};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -365,6 +368,7 @@ impl OllamaClient {
async fn try_generate(
&self,
mut span: Option<&mut opentelemetry::global::BoxedSpan>,
url: &str,
model: &str,
prompt: &str,
@@ -382,15 +386,30 @@ impl OllamaClient {
think,
};
let response = self
let mut request_builder = self
.client
.post(format!("{}/api/generate", url))
.json(&request)
.send()
.await?;
.json(&request);
if let Some(s) = span.as_mut() {
request_builder = crate::otel::with_traceparent(request_builder, s.span_context());
}
let response = match request_builder.send().await {
Ok(r) => r,
Err(e) => {
if let Some(s) = span.as_mut() {
s.set_status(Status::error("Ollama request failed"));
}
return Err(e.into());
}
};
if !response.status().is_success() {
let status = response.status();
crate::otel::record_http_failure(
span.as_deref_mut(),
status.as_u16(),
&format!("Ollama request failed: {status}"),
);
let error_body = response.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"Ollama request failed: {} - {}",
@@ -398,8 +417,26 @@ impl OllamaClient {
error_body
));
}
crate::otel::set_http_status(span.as_deref_mut(), response.status().as_u16());
let result: OllamaResponse = response.json().await?;
let result: OllamaResponse = match response.json().await {
Ok(r) => r,
Err(e) => {
if let Some(s) = span.as_mut() {
s.set_status(Status::error("parsing Ollama response failed"));
}
return Err(e.into());
}
};
if let Some(s) = span.as_mut() {
if let Some(pt) = result.prompt_eval_count {
s.set_attribute(KeyValue::new("llm.tokens.input", pt as i64));
}
if let Some(ct) = result.eval_count {
s.set_attribute(KeyValue::new("llm.tokens.output", ct as i64));
}
s.set_status(Status::Ok);
}
log_chat_metrics(
result.prompt_eval_count,
result.prompt_eval_duration,
@@ -409,32 +446,55 @@ impl OllamaClient {
Ok(result.response)
}
pub async fn generate(&self, prompt: &str, system: Option<&str>) -> Result<String> {
self.generate_with_images(prompt, system, None).await
/// Start the `llm.request` span for an outgoing Ollama call.
fn start_request_span(
&self,
cx: &Context,
operation: &str,
model: &str,
) -> opentelemetry::global::BoxedSpan {
crate::otel::start_llm_request_span(cx, "ollama", operation, model)
}
pub async fn generate(
&self,
cx: &Context,
prompt: &str,
system: Option<&str>,
) -> Result<String> {
self.generate_with_images(cx, prompt, system, None).await
}
#[allow(dead_code)]
pub async fn generate_no_think(&self, prompt: &str, system: Option<&str>) -> Result<String> {
self.generate_with_options(prompt, system, None, Some(false))
pub async fn generate_no_think(
&self,
cx: &Context,
prompt: &str,
system: Option<&str>,
) -> Result<String> {
self.generate_with_options(cx, prompt, system, None, Some(false), "generate")
.await
}
pub async fn generate_with_images(
&self,
cx: &Context,
prompt: &str,
system: Option<&str>,
images: Option<Vec<String>>,
) -> Result<String> {
self.generate_with_options(prompt, system, images, None)
self.generate_with_options(cx, prompt, system, images, None, "generate")
.await
}
async fn generate_with_options(
&self,
cx: &Context,
prompt: &str,
system: Option<&str>,
images: Option<Vec<String>>,
think: Option<bool>,
operation: &str,
) -> Result<String> {
log::debug!("=== Ollama Request ===");
log::debug!("Primary model: {}", self.primary_model);
@@ -447,6 +507,10 @@ impl OllamaClient {
}
log::debug!("=====================");
// Span for the whole generate attempt; the model attribute
// reflects whichever server actually answered.
let mut span = self.start_request_span(cx, operation, &self.primary_model);
// Try primary server first with primary model
log::info!(
"Attempting to generate with primary server: {} (model: {})",
@@ -455,6 +519,7 @@ impl OllamaClient {
);
let primary_result = self
.try_generate(
Some(&mut span),
&self.primary_url,
&self.primary_model,
prompt,
@@ -483,8 +548,10 @@ impl OllamaClient {
fallback_url,
fallback_model
);
span.set_attribute(KeyValue::new("llm.model", fallback_model.to_string()));
match self
.try_generate(
Some(&mut span),
fallback_url,
fallback_model,
prompt,
@@ -531,6 +598,7 @@ impl OllamaClient {
/// Generate a title for a single photo based on its generated summary
pub async fn generate_photo_title(
&self,
cx: &Context,
summary: &str,
custom_system: Option<&str>,
) -> Result<String> {
@@ -546,7 +614,7 @@ Capture the key moment or theme. Return ONLY the title, nothing else."#,
let system = custom_system.unwrap_or("You are my long term memory assistant. Use only the information provided. Do not invent details.");
let title = self
.generate_with_images(&prompt, Some(system), None)
.generate_with_images(cx, &prompt, Some(system), None)
.await?;
// Models decorate despite "Return ONLY the title": quotes, bold
// markers, sometimes a "Title:" label.
@@ -563,6 +631,7 @@ Capture the key moment or theme. Return ONLY the title, nothing else."#,
/// Generate a summary for a single photo based on its context
pub async fn generate_photo_summary(
&self,
cx: &Context,
date: NaiveDate,
location: Option<&str>,
contact: Option<&str>,
@@ -645,21 +714,32 @@ Analyze the image and use specific details from both the visual content and the
let system = custom_system.unwrap_or("You are a memory refreshing assistant who is able to provide insights through analyzing past conversations. Use only the information provided. Do not invent details.");
let images = image_base64.map(|img| vec![img]);
self.generate_with_images(&prompt, Some(system), images)
self.generate_with_images(cx, &prompt, Some(system), images)
.await
}
/// Generate a brief visual description of a photo for use in RAG query enrichment.
/// Returns 1-2 sentences describing people, location, and activity visible in the image.
/// Only called when the model has vision capabilities.
pub async fn generate_photo_description(&self, image_base64: &str) -> Result<String> {
pub async fn generate_photo_description(
&self,
cx: &Context,
image_base64: &str,
) -> Result<String> {
let prompt = "Briefly describe what you see in this image in 1-2 sentences. \
Focus on the people, location, and activity.";
let system = "You are a scene description assistant. Be concise and factual.";
let images = vec![image_base64.to_string()];
let description = self
.generate_with_images(prompt, Some(system), Some(images))
.generate_with_options(
cx,
prompt,
Some(system),
Some(images),
None,
"describe_image",
)
.await?;
Ok(description.trim().to_string())
@@ -672,9 +752,11 @@ Analyze the image and use specific details from both the visual content and the
/// iteration of the agent loop.
pub async fn chat_with_tools(
&self,
cx: &Context,
messages: Vec<ChatMessage>,
tools: Vec<Tool>,
) -> Result<(ChatMessage, Option<i32>, Option<i32>)> {
let mut span = self.start_request_span(cx, "chat", &self.primary_model);
let order = self.attempt_order();
let mut errors: Vec<String> = Vec::new();
@@ -685,8 +767,11 @@ Analyze the image and use specific details from both the visual content and the
url,
model
);
if *label == "fallback" {
span.set_attribute(KeyValue::new("llm.model", model.to_string()));
}
match self
.try_chat_with_tools(url, messages.clone(), tools.clone())
.try_chat_with_tools(Some(&mut span), url, messages.clone(), tools.clone())
.await
{
Ok(result) => {
@@ -728,18 +813,22 @@ Analyze the image and use specific details from both the visual content and the
/// any, live on the final message).
pub async fn chat_with_tools_stream(
&self,
cx: &Context,
messages: Vec<ChatMessage>,
tools: Vec<Tool>,
) -> Result<BoxStream<'static, Result<LlmStreamEvent>>> {
// Same preference logic as `chat_with_tools`. Only the initial
// connection is retried across servers — once the stream begins,
// mid-stream errors propagate to the caller.
// mid-stream errors propagate to the caller. Each attempt gets its
// own `llm.request` span, created inside
// `try_chat_with_tools_stream` so the winning attempt's span moves
// into the stream and is finalized when the response drains.
let order = self.attempt_order();
let mut last_err: Option<anyhow::Error> = None;
for (label, url, _model) in &order {
match self
.try_chat_with_tools_stream(url, messages.clone(), tools.clone())
.try_chat_with_tools_stream(cx, url, messages.clone(), tools.clone())
.await
{
Ok(s) => {
@@ -759,6 +848,7 @@ Analyze the image and use specific details from both the visual content and the
async fn try_chat_with_tools_stream(
&self,
cx: &Context,
base_url: &str,
messages: Vec<ChatMessage>,
tools: Vec<Tool>,
@@ -773,6 +863,11 @@ Analyze the image and use specific details from both the visual content and the
};
let options = self.build_options();
// The span outlives this method — the stream yields until the
// response fully drains, so the owned span moves into the
// generator and is finalized there.
let mut span = self.start_request_span(cx, "chat_stream", model);
let request_body = OllamaChatRequest {
model,
messages: &messages,
@@ -781,16 +876,26 @@ Analyze the image and use specific details from both the visual content and the
options,
};
let response = self
.client
.post(&url)
.json(&request_body)
.send()
.await
.with_context(|| format!("Failed to connect to Ollama at {}", url))?;
let response = {
let mut rb = self.client.post(&url).json(&request_body);
rb = crate::otel::with_traceparent(rb, span.span_context());
match rb.send().await {
Ok(r) => r,
Err(e) => {
span.set_status(Status::error("Ollama stream connect failed"));
return Err(e)
.with_context(|| format!("Failed to connect to Ollama at {}", url));
}
}
};
if !response.status().is_success() {
let status = response.status();
crate::otel::record_http_failure(
Some(&mut span),
status.as_u16(),
&format!("Ollama stream request failed: {status}"),
);
let body = response.text().await.unwrap_or_default();
anyhow::bail!(
"Ollama stream request failed with status {}: {}",
@@ -798,6 +903,7 @@ Analyze the image and use specific details from both the visual content and the
body
);
}
crate::otel::set_http_status(Some(&mut span), response.status().as_u16());
// Ollama streams NDJSON: each line is a full `OllamaStreamChunk`.
// We buffer partial lines across chunks from the byte stream.
@@ -879,6 +985,13 @@ Analyze the image and use specific details from both the visual content and the
eval_count,
eval_duration,
);
if let Some(pt) = prompt_eval_count {
span.set_attribute(KeyValue::new("llm.tokens.input", pt as i64));
}
if let Some(ct) = eval_count {
span.set_attribute(KeyValue::new("llm.tokens.output", ct as i64));
}
span.set_status(Status::Ok);
let message = ChatMessage {
role,
content: accumulated,
@@ -897,6 +1010,7 @@ Analyze the image and use specific details from both the visual content and the
async fn try_chat_with_tools(
&self,
mut span: Option<&mut opentelemetry::global::BoxedSpan>,
base_url: &str,
messages: Vec<ChatMessage>,
tools: Vec<Tool>,
@@ -924,16 +1038,30 @@ Analyze the image and use specific details from both the visual content and the
.unwrap_or_else(|e| format!("<serialization error: {}>", e));
log::debug!("chat_with_tools request body: {}", request_json);
let response = self
.client
.post(&url)
.json(&request_body)
.send()
.await
.with_context(|| format!("Failed to connect to Ollama at {}", url))?;
let response = {
let mut rb = self.client.post(&url).json(&request_body);
if let Some(s) = span.as_mut() {
rb = crate::otel::with_traceparent(rb, s.span_context());
}
match rb.send().await {
Ok(r) => r,
Err(e) => {
if let Some(s) = span.as_mut() {
s.set_status(Status::error("Ollama chat connect failed"));
}
return Err(e)
.with_context(|| format!("Failed to connect to Ollama at {}", url));
}
}
};
if !response.status().is_success() {
let status = response.status();
crate::otel::record_http_failure(
span.as_deref_mut(),
status.as_u16(),
&format!("Ollama chat request failed: {status}"),
);
let body = response.text().await.unwrap_or_default();
// warn, not error — the outer `chat_with_tools` may recover via
// the fallback server. When both fail, the outer layer emits the
@@ -950,11 +1078,26 @@ Analyze the image and use specific details from both the visual content and the
body
);
}
crate::otel::set_http_status(span.as_deref_mut(), response.status().as_u16());
let chat_response: OllamaChatResponse = response
.json()
.await
.with_context(|| "Failed to parse Ollama chat response")?;
let chat_response: OllamaChatResponse = match response.json().await {
Ok(r) => r,
Err(e) => {
if let Some(s) = span.as_mut() {
s.set_status(Status::error("parsing Ollama chat response failed"));
}
return Err(e).with_context(|| "Failed to parse Ollama chat response");
}
};
if let Some(s) = span.as_mut() {
if let Some(pt) = chat_response.prompt_eval_count {
s.set_attribute(KeyValue::new("llm.tokens.input", pt as i64));
}
if let Some(ct) = chat_response.eval_count {
s.set_attribute(KeyValue::new("llm.tokens.output", ct as i64));
}
s.set_status(Status::Ok);
}
// Log performance counters returned by Ollama. Durations are
// reported in nanoseconds; we render ms + tokens/sec for skim-ability
@@ -976,8 +1119,8 @@ Analyze the image and use specific details from both the visual content and the
/// Generate an embedding vector for text using nomic-embed-text:v1.5
/// Returns a 768-dimensional vector as Vec<f32>
pub async fn generate_embedding(&self, text: &str) -> Result<Vec<f32>> {
let embeddings = self.generate_embeddings(&[text]).await?;
pub async fn generate_embedding(&self, cx: &Context, text: &str) -> Result<Vec<f32>> {
let embeddings = self.generate_embeddings(cx, &[text]).await?;
embeddings
.into_iter()
.next()
@@ -987,9 +1130,11 @@ Analyze the image and use specific details from both the visual content and the
/// Generate embeddings for multiple texts in a single API call (batch mode)
/// Returns a vector of 768-dimensional vectors
/// This is much more efficient than calling generate_embedding multiple times
pub async fn generate_embeddings(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
pub async fn generate_embeddings(&self, cx: &Context, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
let embedding_model = EMBEDDING_MODEL;
let mut span = self.start_request_span(cx, "embeddings", embedding_model);
log::debug!("=== Ollama Batch Embedding Request ===");
log::debug!("Model: {}", embedding_model);
log::debug!("Batch size: {} texts", texts.len());
@@ -1003,7 +1148,7 @@ Analyze the image and use specific details from both the visual content and the
embedding_model
);
let primary_result = self
.try_generate_embeddings(&self.primary_url, embedding_model, texts)
.try_generate_embeddings(Some(&mut span), &self.primary_url, embedding_model, texts)
.await;
let embeddings = match primary_result {
@@ -1026,7 +1171,12 @@ Analyze the image and use specific details from both the visual content and the
embedding_model
);
match self
.try_generate_embeddings(fallback_url, embedding_model, texts)
.try_generate_embeddings(
Some(&mut span),
fallback_url,
embedding_model,
texts,
)
.await
{
Ok(embeddings) => {
@@ -1073,6 +1223,7 @@ Analyze the image and use specific details from both the visual content and the
/// Internal helper to try generating embeddings for multiple texts from a specific server
async fn try_generate_embeddings(
&self,
mut span: Option<&mut opentelemetry::global::BoxedSpan>,
url: &str,
model: &str,
texts: &[&str],
@@ -1082,15 +1233,32 @@ Analyze the image and use specific details from both the visual content and the
input: texts.iter().map(|s| s.to_string()).collect(),
};
let response = self
.client
.post(format!("{}/api/embed", url))
.json(&request)
.send()
.await?;
let response = {
let mut rb = self
.client
.post(format!("{}/api/embed", url))
.json(&request);
if let Some(s) = span.as_mut() {
rb = crate::otel::with_traceparent(rb, s.span_context());
}
match rb.send().await {
Ok(r) => r,
Err(e) => {
if let Some(s) = span.as_mut() {
s.set_status(Status::error("Ollama embedding request failed"));
}
return Err(e.into());
}
}
};
if !response.status().is_success() {
let status = response.status();
crate::otel::record_http_failure(
span.as_deref_mut(),
status.as_u16(),
&format!("Ollama batch embedding request failed: {status}"),
);
let error_body = response.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"Ollama batch embedding request failed: {} - {}",
@@ -1098,8 +1266,20 @@ Analyze the image and use specific details from both the visual content and the
error_body
));
}
crate::otel::set_http_status(span.as_deref_mut(), response.status().as_u16());
let result: OllamaEmbedResponse = response.json().await?;
let result: OllamaEmbedResponse = match response.json().await {
Ok(r) => r,
Err(e) => {
if let Some(s) = span.as_mut() {
s.set_status(Status::error("parsing Ollama embed response failed"));
}
return Err(e.into());
}
};
if let Some(s) = span.as_mut() {
s.set_status(Status::Ok);
}
Ok(result.embeddings)
}
}
@@ -1108,35 +1288,38 @@ Analyze the image and use specific details from both the visual content and the
impl LlmClient for OllamaClient {
async fn generate(
&self,
cx: &Context,
prompt: &str,
system: Option<&str>,
images: Option<Vec<String>>,
) -> Result<String> {
self.generate_with_images(prompt, system, images).await
self.generate_with_images(cx, prompt, system, images).await
}
async fn chat_with_tools(
&self,
cx: &Context,
messages: Vec<ChatMessage>,
tools: Vec<Tool>,
) -> Result<(ChatMessage, Option<i32>, Option<i32>)> {
OllamaClient::chat_with_tools(self, messages, tools).await
OllamaClient::chat_with_tools(self, cx, messages, tools).await
}
async fn chat_with_tools_stream(
&self,
cx: &Context,
messages: Vec<ChatMessage>,
tools: Vec<Tool>,
) -> Result<BoxStream<'static, Result<LlmStreamEvent>>> {
OllamaClient::chat_with_tools_stream(self, messages, tools).await
OllamaClient::chat_with_tools_stream(self, cx, messages, tools).await
}
async fn generate_embeddings(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
OllamaClient::generate_embeddings(self, texts).await
async fn generate_embeddings(&self, cx: &Context, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
OllamaClient::generate_embeddings(self, cx, texts).await
}
async fn describe_image(&self, image_base64: &str) -> Result<String> {
self.generate_photo_description(image_base64).await
async fn describe_image(&self, cx: &Context, image_base64: &str) -> Result<String> {
self.generate_photo_description(cx, image_base64).await
}
async fn list_models(&self) -> Result<Vec<ModelCapabilities>> {
+202 -83
View File
@@ -2,8 +2,11 @@
// the translation helpers directly.
#![allow(dead_code)]
use anyhow::{Context, Result, anyhow, bail};
use anyhow::Context as _;
use anyhow::{Result, anyhow, bail};
use async_trait::async_trait;
use opentelemetry::trace::{Span, Status};
use opentelemetry::{Context, KeyValue};
use reqwest::Client;
use serde::Deserialize;
use serde_json::{Value, json};
@@ -14,6 +17,7 @@ use std::time::{Duration, Instant};
use crate::ai::llm_client::{
ChatMessage, LlmClient, LlmStreamEvent, ModelCapabilities, Tool, ToolCall, ToolCallFunction,
};
use futures::stream::{BoxStream, StreamExt};
const DEFAULT_BASE_URL: &str = "https://openrouter.ai/api/v1";
@@ -144,6 +148,19 @@ impl OpenRouterClient {
b
}
/// Start the `llm.request` span for an outgoing call. `operation` is
/// `chat`, `embeddings`, or `describe` — the call shape, not the entry
/// point (a `describe_image` funneled through `chat_with_tools` is
/// still a `describe`).
fn start_request_span(
&self,
cx: &Context,
operation: &str,
model: &str,
) -> opentelemetry::global::BoxedSpan {
crate::otel::start_llm_request_span(cx, "openrouter", operation, model)
}
/// Translate canonical messages to the OpenAI-compatible wire shape.
///
/// Walks in order so it can attach `tool_call_id` to `role=tool` messages
@@ -312,6 +329,7 @@ impl OpenRouterClient {
impl LlmClient for OpenRouterClient {
async fn generate(
&self,
cx: &Context,
prompt: &str,
system: Option<&str>,
images: Option<Vec<String>>,
@@ -324,90 +342,34 @@ impl LlmClient for OpenRouterClient {
user.images = images;
messages.push(user);
let (reply, _, _) = self.chat_with_tools(messages, Vec::new()).await?;
// Delegates to chat_with_tools, which starts the llm.request span
// (operation=chat) — one span per HTTP request.
let (reply, _, _) = self.chat_with_tools(cx, messages, Vec::new()).await?;
Ok(reply.content)
}
async fn chat_with_tools(
&self,
cx: &Context,
messages: Vec<ChatMessage>,
tools: Vec<Tool>,
) -> Result<(ChatMessage, Option<i32>, Option<i32>)> {
let url = format!("{}/chat/completions", self.base_url);
let mut body = serde_json::Map::new();
body.insert("model".into(), Value::String(self.primary_model.clone()));
body.insert(
"messages".into(),
Value::Array(Self::messages_to_openai(&messages)),
);
body.insert("stream".into(), Value::Bool(false));
if !tools.is_empty() {
body.insert(
"tools".into(),
serde_json::to_value(&tools).context("serializing tools")?,
);
}
for (k, v) in self.build_options() {
body.insert(k.into(), v);
}
log::info!(
"OpenRouter chat_with_tools: model={} messages={} tools={}",
self.primary_model,
messages.len(),
tools.len()
);
let resp = self
.authed(self.client.post(&url))
.json(&Value::Object(body))
.send()
let mut span = self.start_request_span(cx, "chat", &self.primary_model);
self.chat_with_tools_impl(Some(&mut span), messages, tools)
.await
.with_context(|| format!("POST {} failed", url))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("OpenRouter chat request failed: {} — {}", status, body);
}
let parsed: Value = resp.json().await.context("parsing chat response")?;
let choice = parsed
.get("choices")
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.ok_or_else(|| {
anyhow!(
"response missing choices[0]: {}",
extract_openrouter_error_detail(&parsed)
)
})?;
let msg = choice.get("message").ok_or_else(|| {
anyhow!(
"choices[0] missing message: {}",
extract_openrouter_error_detail(&parsed)
)
})?;
let chat_msg = Self::openai_message_to_chat(msg)?;
let usage = parsed.get("usage");
let prompt_tokens = usage
.and_then(|u| u.get("prompt_tokens"))
.and_then(|v| v.as_i64())
.map(|n| n as i32);
let completion_tokens = usage
.and_then(|u| u.get("completion_tokens"))
.and_then(|v| v.as_i64())
.map(|n| n as i32);
Ok((chat_msg, prompt_tokens, completion_tokens))
}
async fn chat_with_tools_stream(
&self,
cx: &Context,
messages: Vec<ChatMessage>,
tools: Vec<Tool>,
) -> Result<BoxStream<'static, Result<LlmStreamEvent>>> {
// The span outlives this method — the stream yields until the
// response fully drains, so the owned span moves into the
// generator and is finalized there.
let mut span = self.start_request_span(cx, "chat_stream", &self.primary_model);
let url = format!("{}/chat/completions", self.base_url);
let mut body = serde_json::Map::new();
body.insert("model".into(), Value::String(self.primary_model.clone()));
@@ -432,18 +394,26 @@ impl LlmClient for OpenRouterClient {
body.insert(k.into(), v);
}
let resp = self
let mut request = self
.authed(self.client.post(&url))
.json(&Value::Object(body))
.json(&Value::Object(body));
request = crate::otel::with_traceparent(request, span.span_context());
let resp = request
.send()
.await
.with_context(|| format!("POST {} failed", url))?;
if !resp.status().is_success() {
let status = resp.status();
crate::otel::record_http_failure(
Some(&mut span),
status.as_u16(),
&format!("OpenRouter stream request failed: {status}"),
);
let body = resp.text().await.unwrap_or_default();
bail!("OpenRouter stream request failed: {} — {}", status, body);
}
crate::otel::set_http_status(Some(&mut span), resp.status().as_u16());
// OpenAI-compat SSE stream. Each event is `data: <json>\n\n`, with
// `data: [DONE]` signalling completion. Tool calls arrive as
@@ -467,6 +437,7 @@ impl LlmClient for OpenRouterClient {
let chunk = match chunk {
Ok(b) => b,
Err(e) => {
span.set_status(Status::error(format!("stream read failed: {e}")));
yield Err(anyhow!("stream read failed: {}", e));
return;
}
@@ -517,6 +488,9 @@ impl LlmClient for OpenRouterClient {
.get("completion_tokens")
.and_then(|n| n.as_i64())
.map(|n| n as i32);
if let Some(cost) = usage.get("cost").and_then(|c| c.as_f64()) {
span.set_attribute(KeyValue::new("llm.cost_usd", cost));
}
}
let Some(choices) = v.get("choices").and_then(|c| c.as_array())
@@ -606,6 +580,13 @@ impl LlmClient for OpenRouterClient {
tool_calls,
images: None,
};
if let Some(pt) = prompt_tokens {
span.set_attribute(KeyValue::new("llm.tokens.input", pt as i64));
}
if let Some(ct) = completion_tokens {
span.set_attribute(KeyValue::new("llm.tokens.output", ct as i64));
}
span.set_status(Status::Ok);
yield Ok(LlmStreamEvent::Done {
message,
prompt_eval_count: prompt_tokens,
@@ -616,25 +597,35 @@ impl LlmClient for OpenRouterClient {
Ok(Box::pin(stream))
}
async fn generate_embeddings(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
async fn generate_embeddings(&self, cx: &Context, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
let mut span = self.start_request_span(cx, "embeddings", &self.embedding_model);
let url = format!("{}/embeddings", self.base_url);
let body = json!({
"model": self.embedding_model,
"input": texts,
});
let resp = self
.authed(self.client.post(&url))
.json(&body)
let mut request = self.authed(self.client.post(&url)).json(&body);
request = crate::otel::with_traceparent(request, span.span_context());
let resp = request
.send()
.await
.with_context(|| format!("POST {} failed", url))?;
.with_context(|| format!("POST {} failed", url))
.inspect_err(|_| {
span.set_status(Status::error("OpenRouter embedding request failed"));
})?;
if !resp.status().is_success() {
let status = resp.status();
crate::otel::record_http_failure(
Some(&mut span),
status.as_u16(),
&format!("OpenRouter embedding request failed: {status}"),
);
let body = resp.text().await.unwrap_or_default();
bail!("OpenRouter embedding request failed: {} — {}", status, body);
}
crate::otel::set_http_status(Some(&mut span), resp.status().as_u16());
#[derive(Deserialize)]
struct EmbedResponse {
@@ -646,18 +637,28 @@ impl LlmClient for OpenRouterClient {
}
let parsed: EmbedResponse = resp.json().await.context("parsing embed response")?;
span.set_status(Status::Ok);
Ok(parsed.data.into_iter().map(|i| i.embedding).collect())
}
async fn describe_image(&self, image_base64: &str) -> Result<String> {
async fn describe_image(&self, cx: &Context, image_base64: &str) -> Result<String> {
// Own span with operation=describe_image — the funnel into
// chat_with_tools_impl keeps the request on a single span.
let mut span = self.start_request_span(cx, "describe_image", &self.primary_model);
let prompt = "Briefly describe what you see in this image in 1-2 sentences. \
Focus on the people, location, and activity.";
self.generate(
prompt,
Some("You are a scene description assistant. Be concise and factual."),
Some(vec![image_base64.to_string()]),
)
.await
let mut messages: Vec<ChatMessage> = Vec::new();
messages.push(ChatMessage::system(
"You are a scene description assistant. Be concise and factual.",
));
let mut user = ChatMessage::user(prompt);
user.images = Some(vec![image_base64.to_string()]);
messages.push(user);
let (reply, _, _) = self
.chat_with_tools_impl(Some(&mut span), messages, Vec::new())
.await?;
Ok(reply.content)
}
async fn list_models(&self) -> Result<Vec<ModelCapabilities>> {
@@ -711,6 +712,124 @@ impl LlmClient for OpenRouterClient {
}
}
impl OpenRouterClient {
/// HTTP core of `chat_with_tools`. `span` is the `llm.request` span
/// started by the caller (or a funnelling entry point like
/// `describe_image`) so attributes from the response land on the right
/// operation label.
async fn chat_with_tools_impl(
&self,
mut span: Option<&mut opentelemetry::global::BoxedSpan>,
messages: Vec<ChatMessage>,
tools: Vec<Tool>,
) -> Result<(ChatMessage, Option<i32>, Option<i32>)> {
let url = format!("{}/chat/completions", self.base_url);
let mut body = serde_json::Map::new();
body.insert("model".into(), Value::String(self.primary_model.clone()));
body.insert(
"messages".into(),
Value::Array(Self::messages_to_openai(&messages)),
);
body.insert("stream".into(), Value::Bool(false));
if !tools.is_empty() {
body.insert(
"tools".into(),
serde_json::to_value(&tools).context("serializing tools")?,
);
}
for (k, v) in self.build_options() {
body.insert(k.into(), v);
}
log::info!(
"OpenRouter chat_with_tools: model={} messages={} tools={}",
self.primary_model,
messages.len(),
tools.len()
);
let mut request = self
.authed(self.client.post(&url))
.json(&Value::Object(body));
if let Some(s) = span.as_mut() {
request = crate::otel::with_traceparent(request, s.span_context());
}
let resp = match request.send().await {
Ok(r) => r,
Err(e) => {
if let Some(s) = span.as_mut() {
s.set_status(Status::error("OpenRouter request failed"));
}
return Err(e).with_context(|| format!("POST {} failed", url));
}
};
if !resp.status().is_success() {
let status = resp.status();
crate::otel::record_http_failure(
span.as_deref_mut(),
status.as_u16(),
&format!("OpenRouter chat request failed: {status}"),
);
let body = resp.text().await.unwrap_or_default();
bail!("OpenRouter chat request failed: {} — {}", status, body);
}
crate::otel::set_http_status(span.as_deref_mut(), resp.status().as_u16());
let parsed: Value = match resp.json().await {
Ok(v) => v,
Err(e) => {
if let Some(s) = span.as_mut() {
s.set_status(Status::error("parsing chat response failed"));
}
return Err(e).context("parsing chat response");
}
};
let choice = parsed
.get("choices")
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.ok_or_else(|| {
anyhow!(
"response missing choices[0]: {}",
extract_openrouter_error_detail(&parsed)
)
})?;
let msg = choice.get("message").ok_or_else(|| {
anyhow!(
"choices[0] missing message: {}",
extract_openrouter_error_detail(&parsed)
)
})?;
let chat_msg = Self::openai_message_to_chat(msg)?;
let usage = parsed.get("usage");
let prompt_tokens = usage
.and_then(|u| u.get("prompt_tokens"))
.and_then(|v| v.as_i64())
.map(|n| n as i32);
let completion_tokens = usage
.and_then(|u| u.get("completion_tokens"))
.and_then(|v| v.as_i64())
.map(|n| n as i32);
// OpenRouter reports per-request USD cost in `usage.cost`.
if let Some(s) = span.as_mut() {
if let Some(pt) = prompt_tokens {
s.set_attribute(KeyValue::new("llm.tokens.input", pt as i64));
}
if let Some(ct) = completion_tokens {
s.set_attribute(KeyValue::new("llm.tokens.output", ct as i64));
}
if let Some(cost) = usage.and_then(|u| u.get("cost")).and_then(|c| c.as_f64()) {
s.set_attribute(KeyValue::new("llm.cost_usd", cost));
}
s.set_status(Status::Ok);
}
Ok((chat_msg, prompt_tokens, completion_tokens))
}
}
/// Extract a diagnostic fragment from an OpenRouter response body that
/// doesn't match the expected `{choices: [...]}` shape. OpenRouter will
/// sometimes return 200 OK with `{"error": {"message": "...", "code": ...}}`
+13 -3
View File
@@ -26,7 +26,7 @@ use actix_web::{HttpRequest, HttpResponse, Responder, delete, get, post, web};
use anyhow::{Context, Result, anyhow, bail};
use chrono::Utc;
use opentelemetry::KeyValue;
use opentelemetry::trace::{Span, Status, Tracer};
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex as StdMutex};
@@ -685,6 +685,7 @@ impl PersonaChatSession {
entry_clone.clone(),
user_id,
library_id,
&mut span,
)
.await;
match result {
@@ -721,6 +722,7 @@ impl PersonaChatSession {
entry: Arc<TurnEntry>,
user_id: i32,
_library_id: i32,
turn_span: &mut opentelemetry::global::BoxedSpan,
) -> Result<()> {
// Per-conversation mutex — serializes this turn against a
// concurrent rewind or branch switch on the same transcript.
@@ -808,6 +810,12 @@ impl PersonaChatSession {
.resolve_backend(backend_kind, &overrides)
.await?;
let model_used = backend.model().to_string();
// llm.* attributes on the turn-level span (issue #130), plus the
// parent context for the child llm.request spans.
turn_span.set_attribute(KeyValue::new("llm.backend", backend.kind.as_str()));
turn_span.set_attribute(KeyValue::new("llm.model", model_used.clone()));
let turn_cx = opentelemetry::Context::new()
.with_remote_span_context(turn_span.span_context().clone());
// Build a no-photo tool catalog. `current_gate_opts_for_persona`
// already probes each tool's backing table for presence and
@@ -874,6 +882,7 @@ impl PersonaChatSession {
&persona.persona_id,
max_iterations,
&entry,
&turn_cx,
)
.await
.map_err(|e| anyhow!("persona chat loop failed: {e:?}"))?;
@@ -942,7 +951,7 @@ impl PersonaChatSession {
// list falls back to the persona name while the title is empty.
if needs_title {
let title = self
.generate_title(&backend, &messages)
.generate_title(&backend, &messages, &turn_cx)
.await
.unwrap_or_else(|| fallback_title(&messages));
if !title.is_empty() {
@@ -1047,6 +1056,7 @@ impl PersonaChatSession {
&self,
backend: &crate::ai::backend::ResolvedBackend,
messages: &[ChatMessage],
cx: &opentelemetry::Context,
) -> Option<String> {
let user_message = messages.iter().find(|m| m.role == "user")?;
// The last assistant message with actual prose — earlier ones may be
@@ -1057,7 +1067,7 @@ impl PersonaChatSession {
.find(|m| m.role == "assistant" && !m.content.trim().is_empty())?;
let prompt = title_prompt(&user_message.content, &reply.content);
match backend.chat().generate(&prompt, None, None).await {
match backend.chat().generate(cx, &prompt, None, None).await {
Ok(raw) => {
let title = sanitize_title(&crate::ai::llm_client::strip_think_blocks(&raw));
if title.is_empty() { None } else { Some(title) }
+2
View File
@@ -324,6 +324,7 @@ impl SmsApiClient {
&self,
messages: &[SmsMessage],
ollama: &OllamaClient,
cx: &opentelemetry::Context,
) -> Result<String> {
if messages.is_empty() {
return Ok(String::from("No messages on this day"));
@@ -356,6 +357,7 @@ Summary:"#,
ollama
.generate(
cx,
&prompt,
// Some("You are a summarizer for the purposes of jogging my memory and highlighting events and situations."),
Some("You are the keeper of memories, ingest the context and give me a casual summary of the moment."),
+76
View File
@@ -0,0 +1,76 @@
//! Pre-cutover audit utility for the filename date class (ticket #117).
//!
//! One-shot, operator-run: lists every file matching a filename-class
//! pattern across the configured libraries — the file's current stored
//! date + source and the date / source / offset-source the re-based
//! resolver would assign it. Strictly read-only: it changes nothing.
//!
//! This is the human spot-check that gates the production backfill
//! drain: review the report, fix any mis-tagged pattern or
//! misclassified file, then start the drain.
//!
//! Usage: `cargo run --bin filename_audit` (reads DATABASE_URL and
//! EXCLUDED_DIRS from the environment, like the server).
use std::path::PathBuf;
use diesel::Connection;
use diesel::sqlite::SqliteConnection;
use image_api::filename_audit::{format_report, run_audit};
use image_api::libraries::load_all;
fn main() -> anyhow::Result<()> {
env_logger::init();
dotenv::dotenv().ok();
let database_url = dotenv::var("DATABASE_URL")
.map_err(|_| anyhow::anyhow!("DATABASE_URL is not set (check .env)"))?;
let base_path = dotenv::var("BASE_PATH").ok();
let mut conn =
SqliteConnection::establish(&database_url).map_err(|e| anyhow::anyhow!(e.to_string()))?;
// Migrations are already run by the server; the audit only SELECTs.
// A fresh database without the date-class columns would fail the
// select below with a clear SQL error — that is the expected signal.
let global_excludes: Vec<String> = dotenv::var("EXCLUDED_DIRS")
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
let libraries = load_all(&mut conn);
if libraries.is_empty() {
anyhow::bail!(
"no rows in the `libraries` table — is DATABASE_URL pointing at the live database?"
);
}
// BASE_PATH is informational for the operator (the libraries table
// carries the authoritative roots); warn when it differs from every
// known root so a stale env var doesn't mislead the review.
if let Some(base) = &base_path
&& let base = PathBuf::from(base)
&& !libraries
.iter()
.any(|l| PathBuf::from(&l.root_path) == base)
{
eprintln!(
"note: BASE_PATH ({}) does not match any library root; auditing the library table",
base.display()
);
}
println!("Filename date class audit (read-only)");
println!("======================================");
println!("Libraries: {}", libraries.len());
println!();
let (rows, skipped) = run_audit(&mut conn, libraries, &global_excludes);
print!("{}", format_report(&rows, &skipped));
Ok(())
}
+4
View File
@@ -185,6 +185,9 @@ async fn main() -> anyhow::Result<()> {
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 user_ai_prefs_dao: Arc<Mutex<Box<dyn image_api::database::UserAiPrefsDao>>> = Arc::new(
Mutex::new(Box::new(image_api::database::SqliteUserAiPrefsDao::new())),
);
let persona_dao: Arc<Mutex<Box<dyn image_api::database::PersonaDao>>> = Arc::new(Mutex::new(
Box::new(image_api::database::SqlitePersonaDao::new()),
));
@@ -208,6 +211,7 @@ async fn main() -> anyhow::Result<()> {
face_dao,
knowledge_dao,
persona_dao,
user_ai_prefs_dao,
all_libs.clone(),
);
+6 -2
View File
@@ -220,7 +220,9 @@ async fn main() -> Result<()> {
println!("Generating summary...");
let summary = ollama.generate(&prompt, Some(system_prompt)).await?;
let summary = ollama
.generate(&opentelemetry::Context::new(), &prompt, Some(system_prompt))
.await?;
println!("\n📝 GENERATED SUMMARY:");
println!("─────────────────────────────────────────");
@@ -236,7 +238,9 @@ async fn main() -> Result<()> {
);
println!("\nGenerating embedding...");
let embedding = ollama.generate_embedding(&stripped).await?;
let embedding = ollama
.generate_embedding(&opentelemetry::Context::new(), &stripped)
.await?;
println!("✓ Embedding generated ({} dimensions)", embedding.len());
println!("Saving to database...");
+44 -1
View File
@@ -170,7 +170,16 @@ pub fn resolve_camera_offset(
}
let minutes = match server_zone {
Some(zone) => offset_minutes_for_zone(zone, wall),
None => Local::now().offset().fix().local_minus_utc() / 60,
// Zone name unresolvable on this host: assume the process-local
// zone, but at the PHOTO's wall (a DST host's current-season
// offset would skew historical captures by an hour). Ambiguous
// walls take the earliest (pre-transition) reading; truly
// nonexistent local times fall back to the current offset.
None => match Local.from_local_datetime(&wall) {
LocalResult::Single(dt) => dt.offset().fix().local_minus_utc() / 60,
LocalResult::Ambiguous(dt, _) => dt.offset().fix().local_minus_utc() / 60,
LocalResult::None => Local::now().offset().fix().local_minus_utc() / 60,
},
};
OffsetResolution {
minutes,
@@ -338,6 +347,40 @@ mod tests {
assert_eq!(res.source, TzSource::AssumedServer);
}
/// The offset the process-local zone assigns to a wall clock —
/// independent of the current season.
fn local_offset_at(wall: NaiveDateTime) -> i32 {
Local
.from_local_datetime(&wall)
.earliest()
.map(|dt| dt.offset().fix().local_minus_utc() / 60)
.unwrap_or_else(|| Local::now().offset().fix().local_minus_utc() / 60)
}
#[test]
fn assumed_server_fallback_is_dst_aware_at_the_wall_when_zone_unresolvable() {
// `server_zone() = None` (e.g. a container whose /etc/localtime is a
// copy, not a symlink): the fallback assumes the process-local zone
// AT THE PHOTO'S WALL, not the current season's offset. On a DST
// host this test catches the old behavior whenever the host is in
// either season (one of the two walls disagrees with `now`);
// on a fixed-offset host both assertions hold trivially.
let evidence = OffsetEvidence::default();
let winter = resolve_camera_offset(wall_january(), &evidence, None, &StubLookup(None));
let summer = resolve_camera_offset(wall_july(), &evidence, None, &StubLookup(None));
assert_eq!(
winter.minutes,
local_offset_at(wall_january()),
"winter wall must carry the winter offset"
);
assert_eq!(
summer.minutes,
local_offset_at(wall_july()),
"summer wall must carry the summer offset"
);
assert_eq!(winter.source, TzSource::AssumedServer);
}
#[test]
fn gps_miss_falls_through_to_server_zone() {
let evidence = OffsetEvidence {
+8
View File
@@ -428,7 +428,15 @@ pub struct GpsPhotoSummary {
pub path: String,
pub lat: f64,
pub lon: f64,
/// Legacy mixed-convention date (frozen for existing rows; ADR-0001).
pub date_taken: Option<i64>,
/// True instant (epoch seconds), when the row has been converted.
pub date_utc: Option<i64>,
/// Camera offset in minutes from UTC at capture, when known.
pub camera_offset_minutes: Option<i32>,
/// Which evidence pinned the instant (`offset_tag`, `gps`,
/// `assumed_server`, `utc_direct`, `manual`).
pub date_taken_tz_source: Option<String>,
}
#[derive(Debug, Serialize)]
+179 -124
View File
@@ -9,21 +9,6 @@ use crate::database::models::{
};
use crate::otel::trace_db_call;
/// Decoded shape for `get_memories_in_window`'s raw `sql_query`. Diesel's
/// query DSL doesn't expose strftime, so the memories filter is hand-
/// written SQL — but the returned columns are simple enough that a small
/// `QueryableByName` struct suffices, kept private to this module.
#[derive(diesel::QueryableByName)]
#[allow(dead_code)] // fields read via Diesel's QueryableByName derive
struct MemoriesWindowRow {
#[diesel(sql_type = diesel::sql_types::Text)]
rel_path: String,
#[diesel(sql_type = diesel::sql_types::BigInt)]
date_taken: i64,
#[diesel(sql_type = diesel::sql_types::BigInt)]
last_modified: i64,
}
/// Wire shape for a single member of a duplicate group, returned by
/// `list_duplicates_*` and `lookup_duplicate_row`. Carries everything
/// the Apollo modal needs to render a member tile and its meta line —
@@ -441,7 +426,18 @@ pub trait ExifDao: Sync + Send {
context: &opentelemetry::Context,
base_path: &str,
recursive: bool,
) -> Result<Vec<(String, f64, f64, Option<i64>)>, DbError>;
) -> Result<
Vec<(
String,
f64,
f64,
Option<i64>,
Option<i64>,
Option<i32>,
Option<String>,
)>,
DbError,
>;
/// Return rows that still lack a `content_hash`, oldest first. Used by
/// the `backfill_hashes` binary to batch through the historical
@@ -652,34 +648,34 @@ pub trait ExifDao: Sync + Send {
rel_path: &str,
) -> Result<ImageExif, DbError>;
/// Single-query backend for `/memories`. Returns
/// `(rel_path, date_taken, last_modified)` for rows in `library_id`
/// whose `date_taken` falls within `[now - years_back y, now]` and
/// whose calendar position matches the request's span:
/// - `"day"` — same month + day-of-month (any year)
/// - `"week"` — same week-of-year (SQLite `%W`, Monday-anchored —
/// close to but not exactly ISO week 8601; the boundary cases
/// at year-start/end can shift by ±1 vs the prior request-time
/// `iso_week()` filter)
/// - `"month"` — same month (any year)
/// Coarse pre-filter for the `/memories` span query (ADR-0001).
///
/// `tz_offset_minutes` is applied to both sides of the strftime
/// comparison so the calendar match is in the user's local time.
/// Backed by the `(library_id, date_taken)` index.
/// The span feature matches the same calendar position (month-day /
/// week-of-year / month) across every past year, so this query only
/// bounds the rows by epoch windows; the exact span-day match happens
/// in the caller (`memories::day_in_span`), because it needs the
/// owner's display zone.
///
/// This is the single-SQL replacement for the EXIF-loop +
/// WalkDir-fallback that powered `/memories` previously; it's
/// correct only because the canonical-date waterfall at ingest
/// (`crate::date_resolver`) populates `date_taken` for every row
/// it can resolve.
/// Eligibility:
/// - `date_taken` must be set and sit inside the unconverted band
/// `[unconv_lo, unconv_hi]` (a ±15 h margin around the years-back
/// window that covers every per-source legacy convention, and which
/// also caps how far into the future a row may sit);
/// - converted rows (`date_utc IS NOT NULL`) additionally need
/// `date_utc` inside `[conv_lo, conv_hi]` — the true-instant
/// window; unconverted rows pass on the band alone.
///
/// Returns full rows so the caller can read the date triple
/// (`date_utc` / `camera_offset_minutes` / `date_taken_tz_source`).
fn get_memories_in_window(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
span_token: &str,
years_back: i32,
tz_offset_minutes: i32,
) -> Result<Vec<(String, i64, i64)>, DbError>;
conv_lo: i64,
conv_hi: i64,
unconv_lo: i64,
unconv_hi: i64,
) -> Result<Vec<ImageExif>, DbError>;
/// Return image rows that have a `content_hash` but no `phash_64`,
/// oldest first. Used by the `backfill_perceptual_hash` binary.
@@ -1293,12 +1289,24 @@ impl ExifDao for SqliteExifDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
#[allow(clippy::type_complexity)]
fn get_all_with_gps(
&mut self,
context: &opentelemetry::Context,
base_path: &str,
recursive: bool,
) -> Result<Vec<(String, f64, f64, Option<i64>)>, DbError> {
) -> Result<
Vec<(
String,
f64,
f64,
Option<i64>,
Option<i64>,
Option<i32>,
Option<String>,
)>,
DbError,
> {
trace_db_call(context, "query", "get_all_with_gps", |span| {
use opentelemetry::KeyValue;
use opentelemetry::trace::Span;
@@ -1334,10 +1342,19 @@ impl ExifDao for SqliteExifDao {
.load::<ImageExif>(connection)
.map_err(|e| anyhow::anyhow!("GPS query error: {}", e))?;
// Convert to tuple format (path, lat, lon, date_taken)
// Convert to tuple format (path, lat, lon, legacy date_taken,
// date_utc, camera_offset_minutes, date_taken_tz_source).
// Filter out any rows where GPS is still None (shouldn't happen due to filter)
// Cast f32 GPS values to f64 for API compatibility
let filtered: Vec<(String, f64, f64, Option<i64>)> = results
let filtered: Vec<(
String,
f64,
f64,
Option<i64>,
Option<i64>,
Option<i32>,
Option<String>,
)> = results
.into_iter()
.filter_map(|exif| {
if let (Some(lat_val), Some(lon_val)) = (exif.gps_latitude, exif.gps_longitude)
@@ -1347,6 +1364,9 @@ impl ExifDao for SqliteExifDao {
lat_val as f64,
lon_val as f64,
exif.date_taken,
exif.date_utc,
exif.camera_offset_minutes,
exif.date_taken_tz_source,
))
} else {
None
@@ -1937,51 +1957,27 @@ impl ExifDao for SqliteExifDao {
&mut self,
context: &opentelemetry::Context,
library_id: i32,
span_token: &str,
years_back: i32,
tz_offset_minutes: i32,
) -> Result<Vec<(String, i64, i64)>, DbError> {
conv_lo: i64,
conv_hi: i64,
unconv_lo: i64,
unconv_hi: i64,
) -> Result<Vec<ImageExif>, DbError> {
trace_db_call(context, "query", "get_memories_in_window", |_span| {
// strftime pattern is span-dependent; the rest of the WHERE
// clause is shared. Only `%m-%d`, `%W`, `%m` are accepted —
// anything else is a programmer error.
let pattern = match span_token {
"day" => "%m-%d",
"week" => "%W",
"month" => "%m",
_ => return Err(anyhow::anyhow!("invalid span token: {}", span_token)),
};
// SQLite's date modifiers want a string like `'-480 minutes'`
// (signed) or `'-15 years'`. Use the `+` flag so positive
// offsets render as `+480 minutes`.
let tz_modifier = format!("{:+} minutes", tz_offset_minutes);
let years_modifier = format!("-{} years", years_back);
let sql = format!(
"SELECT rel_path, date_taken, last_modified \
FROM image_exif \
WHERE library_id = ?1 \
AND date_taken IS NOT NULL \
AND date_taken <= unixepoch('now') \
AND date_taken >= unixepoch('now', ?2) \
AND strftime('{p}', date_taken, 'unixepoch', ?3) \
= strftime('{p}', 'now', ?3)",
p = pattern,
);
use crate::database::schema::image_exif::dsl as d;
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
diesel::sql_query(sql)
.bind::<diesel::sql_types::Integer, _>(library_id)
.bind::<diesel::sql_types::Text, _>(years_modifier)
.bind::<diesel::sql_types::Text, _>(tz_modifier)
.load::<MemoriesWindowRow>(connection.deref_mut())
.map(|rows| {
rows.into_iter()
.map(|r| (r.rel_path, r.date_taken, r.last_modified))
.collect()
})
d::image_exif
.filter(d::library_id.eq(library_id))
.filter(d::date_taken.is_not_null())
.filter(d::date_taken.ge(unconv_lo))
.filter(d::date_taken.le(unconv_hi))
.filter(
d::date_utc
.is_null()
.or(d::date_utc.ge(conv_lo).and(d::date_utc.le(conv_hi))),
)
.load::<ImageExif>(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
@@ -2966,25 +2962,21 @@ mod exif_dao_tests {
assert_eq!(row.size_bytes, Some(1024));
}
/// Wide intervals that admit everything in the years-back window —
/// span-day matching happens in the caller, so tests that don't care
/// about the span pass these.
/// Mirrors the production caller (`memories::memories_span_windows`):
/// the converted window spans the full years-back horizon and the
/// unconverted band pads it by ±15 h — so far-future legacy values
/// are dropped at the DAO, not by the band.
fn wide_windows(now_ts: i64, years_back: i64) -> (i64, i64, i64, i64) {
let lo = now_ts - years_back * 366 * 86_400;
(lo, now_ts, lo - 15 * 3600, now_ts + 15 * 3600)
}
#[test]
fn get_memories_in_window_day_matches_only_same_md_in_year_window() {
fn get_memories_in_window_filters_future_no_date_and_years_back() {
let mut dao = setup_two_libraries();
// Anchor on a known date so the test is timezone-stable: insert
// rows whose date_taken IS the same wall-clock time as `now()`
// would have been some N years ago, and verify the day-span
// filter returns them. We can't bind 'now' from Rust, so instead
// we insert rows for the *current* day (offset by 365 days * N
// years) and rely on SQLite computing the same `%m-%d` for both
// sides of the equality. Using the unix-now-minus-365*N seconds
// approximation is good enough — leap years drift by ~one day
// every four years, but the test only checks day-of-year match
// for rows inserted "today minus N years (no leap correction)".
// To dodge the leap-year drift entirely, we use rows whose
// calendar date is read back from SQLite and we just check
// membership.
// 1y, 5y, 10y, 21y back from 'now':
let now_ts = chrono::Utc::now().timestamp();
let year_secs: i64 = 365 * 86_400;
insert_row_with_source(
@@ -2994,13 +2986,6 @@ mod exif_dao_tests {
Some(now_ts - year_secs),
Some("exif"),
);
insert_row_with_source(
&mut dao,
1,
"y5.jpg",
Some(now_ts - 5 * year_secs),
Some("exif"),
);
insert_row_with_source(
&mut dao,
1,
@@ -3027,20 +3012,15 @@ mod exif_dao_tests {
// No date — never returned regardless of source.
insert_row_with_source(&mut dao, 1, "nodate.jpg", None, None);
// Month span returns rows from the same calendar month over the
// window — y1, y5, y10 should all qualify (same month any year),
// y21 trims (out of years_back), future trims (> now), nodate
// never qualifies. Day-of-month leap drift means even with 365-
// day approximation a row may shift by one in either direction;
// month is the safer assertion under that approximation.
let (clo, chi, ulo, uhi) = wide_windows(now_ts, 20);
let rows = dao
.get_memories_in_window(&ctx(), 1, "month", 20, 0)
.get_memories_in_window(&ctx(), 1, clo, chi, ulo, uhi)
.unwrap();
let paths: std::collections::HashSet<String> =
rows.into_iter().map(|(p, _, _)| p).collect();
rows.into_iter().map(|r| r.file_path).collect();
assert!(
paths.contains("y1.jpg") && paths.contains("y5.jpg") && paths.contains("y10.jpg"),
"month span should include all in-window rows: {:?}",
paths.contains("y1.jpg") && paths.contains("y10.jpg"),
"in-window rows should be returned: {:?}",
paths
);
assert!(
@@ -3067,23 +3047,98 @@ mod exif_dao_tests {
Some(now_ts - year),
Some("exif"),
);
let (clo, chi, ulo, uhi) = wide_windows(now_ts, 20);
let lib1 = dao
.get_memories_in_window(&ctx(), 1, "month", 20, 0)
.get_memories_in_window(&ctx(), 1, clo, chi, ulo, uhi)
.unwrap();
let lib2 = dao
.get_memories_in_window(&ctx(), 2, "month", 20, 0)
.get_memories_in_window(&ctx(), 2, clo, chi, ulo, uhi)
.unwrap();
assert_eq!(lib1.len(), 1);
assert_eq!(lib1[0].0, "main/x.jpg");
assert_eq!(lib1[0].file_path, "main/x.jpg");
assert_eq!(lib2.len(), 1);
assert_eq!(lib2[0].0, "archive/x.jpg");
assert_eq!(lib2[0].file_path, "archive/x.jpg");
}
#[test]
fn get_memories_in_window_rejects_unknown_span_token() {
fn get_memories_in_window_branches_converted_on_date_utc() {
let mut dao = setup_two_libraries();
let err = dao.get_memories_in_window(&ctx(), 1, "decade", 20, 0);
assert!(err.is_err());
let now_ts = chrono::Utc::now().timestamp();
let year = 365 * 86_400i64;
// A converted row whose `date_utc` sits INSIDE the conv interval
// but whose legacy `date_taken` sits OUTSIDE the unconv interval:
// it must still be returned (proof the converted branch won).
insert_row_with_source(&mut dao, 1, "conv.jpg", Some(now_ts - year), Some("exif"));
dao.backfill_date_taken(
&ctx(),
1,
"conv.jpg",
now_ts - year,
"exif",
Some(now_ts - year - 9000), // shifted true instant
None,
Some("assumed_server"),
)
.unwrap();
// An unconverted row: judged on legacy `date_taken` only.
insert_row_with_source(
&mut dao,
1,
"unconv.jpg",
Some(now_ts - year),
Some("fs_time"),
);
// A converted row OUTSIDE the conv interval: excluded even though
// its legacy value is inside the unconv band.
insert_row_with_source(
&mut dao,
1,
"conv-out.jpg",
Some(now_ts - year),
Some("exif"),
);
dao.backfill_date_taken(
&ctx(),
1,
"conv-out.jpg",
now_ts - year,
"exif",
Some(now_ts - 30 * year),
None,
Some("assumed_server"),
)
.unwrap();
// conv interval: a tight band around (now - year - 9000) that
// excludes (now - 30*year) and excludes the unconv row's legacy
// (now - year) is irrelevant — it's unconverted. The unconv band
// is wide for unconv.jpg.
let clo = now_ts - year - 86_400;
let chi = now_ts - year;
let ulo = now_ts - year - 86_400;
let uhi = now_ts;
let rows = dao
.get_memories_in_window(&ctx(), 1, clo, chi, ulo, uhi)
.unwrap();
let paths: std::collections::HashSet<String> =
rows.into_iter().map(|r| r.file_path).collect();
assert!(
paths.contains("conv.jpg"),
"converted row in conv band: {:?}",
paths
);
assert!(
paths.contains("unconv.jpg"),
"unconverted row in legacy band: {:?}",
paths
);
assert!(
!paths.contains("conv-out.jpg"),
"converted row outside conv band must be excluded: {:?}",
paths
);
}
}
+18
View File
@@ -579,6 +579,19 @@ pub struct UserAiPrefs {
/// default (`camera_tz::DEFAULT_DISPLAY_TZ`). The legacy
/// `tz_offset_minutes` column stays frozen for rollback.
pub display_tz: Option<String>,
/// Last/explicit insight + chat model choice (local id or OpenRouter id).
/// NULL = current per-request / env default behavior.
pub default_model: Option<String>,
/// `"local"` | `"hybrid"` — insight/chat backend. NULL = per-request /
/// hard default ("local").
pub default_backend: Option<String>,
/// Active persona for persona chat. NULL = hard default ("default").
pub default_persona_id: Option<String>,
/// Reel span: `"day"` | `"week"` | `"month"`. NULL = per-request /
/// hard default (day).
pub reel_span: Option<String>,
/// Reel segment cap. NULL = per-request / hard default.
pub reel_max_segments: Option<i32>,
}
#[derive(Insertable, Debug, Clone, serde::Deserialize, serde::Serialize)]
@@ -589,4 +602,9 @@ pub struct UpsertUserAiPrefs {
pub library: Option<String>,
pub updated_at: i64,
pub display_tz: Option<String>,
pub default_model: Option<String>,
pub default_backend: Option<String>,
pub default_persona_id: Option<String>,
pub reel_span: Option<String>,
pub reel_max_segments: Option<i32>,
}
+5
View File
@@ -293,6 +293,11 @@ diesel::table! {
library -> Nullable<Text>,
updated_at -> BigInt,
display_tz -> Nullable<Text>,
default_model -> Nullable<Text>,
default_backend -> Nullable<Text>,
default_persona_id -> Nullable<Text>,
reel_span -> Nullable<Text>,
reel_max_segments -> Nullable<Integer>,
}
}
+39
View File
@@ -100,6 +100,11 @@ impl UserAiPrefsDao for SqliteUserAiPrefsDao {
dsl::tz_offset_minutes.eq(&prefs.tz_offset_minutes),
dsl::library.eq(&prefs.library),
dsl::display_tz.eq(&prefs.display_tz),
dsl::default_model.eq(&prefs.default_model),
dsl::default_backend.eq(&prefs.default_backend),
dsl::default_persona_id.eq(&prefs.default_persona_id),
dsl::reel_span.eq(&prefs.reel_span),
dsl::reel_max_segments.eq(&prefs.reel_max_segments),
dsl::updated_at.eq(&prefs.updated_at),
))
.execute(connection.deref_mut())
@@ -147,6 +152,11 @@ mod tests {
library: Some("1".to_string()),
updated_at: now,
display_tz: Some("America/New_York".to_string()),
default_model: Some("llama3.2:3b".to_string()),
default_backend: Some("hybrid".to_string()),
default_persona_id: Some("cabin".to_string()),
reel_span: Some("week".to_string()),
reel_max_segments: Some(24),
};
dao.upsert_prefs(&ctx(), &prefs).unwrap();
@@ -156,6 +166,11 @@ mod tests {
assert_eq!(row.tz_offset_minutes, Some(-480));
assert_eq!(row.library, Some("1".to_string()));
assert_eq!(row.display_tz, Some("America/New_York".to_string()));
assert_eq!(row.default_model, Some("llama3.2:3b".to_string()));
assert_eq!(row.default_backend, Some("hybrid".to_string()));
assert_eq!(row.default_persona_id, Some("cabin".to_string()));
assert_eq!(row.reel_span, Some("week".to_string()));
assert_eq!(row.reel_max_segments, Some(24));
assert_eq!(row.updated_at, now);
}
@@ -171,6 +186,11 @@ mod tests {
library: Some("1".to_string()),
updated_at: now1,
display_tz: Some("America/New_York".to_string()),
default_model: Some("llama3.2:3b".to_string()),
default_backend: Some("local".to_string()),
default_persona_id: None,
reel_span: Some("week".to_string()),
reel_max_segments: Some(24),
};
dao.upsert_prefs(&ctx(), &prefs1).unwrap();
@@ -180,6 +200,11 @@ mod tests {
library: None,
updated_at: now2,
display_tz: None,
default_model: None,
default_backend: None,
default_persona_id: None,
reel_span: None,
reel_max_segments: None,
};
dao.upsert_prefs(&ctx(), &prefs2).unwrap();
@@ -188,6 +213,11 @@ mod tests {
assert_eq!(row.tz_offset_minutes, Some(-300));
assert!(row.library.is_none());
assert!(row.display_tz.is_none());
// Owner-default columns are overwritten to NULL like the rest.
assert!(row.default_model.is_none());
assert!(row.default_backend.is_none());
assert!(row.reel_span.is_none());
assert!(row.reel_max_segments.is_none());
assert_eq!(row.updated_at, now2);
}
@@ -202,6 +232,11 @@ mod tests {
library: None,
updated_at: now,
display_tz: Some("Europe/Berlin".to_string()),
default_model: None,
default_backend: None,
default_persona_id: Some("default".to_string()),
reel_span: Some("month".to_string()),
reel_max_segments: None,
};
dao.upsert_prefs(&ctx(), &prefs).unwrap();
@@ -210,5 +245,9 @@ mod tests {
assert!(row.voice.is_none());
assert!(row.library.is_none());
assert_eq!(row.display_tz, Some("Europe/Berlin".to_string()));
assert!(row.default_model.is_none());
assert_eq!(row.default_persona_id, Some("default".to_string()));
assert_eq!(row.reel_span, Some("month".to_string()));
assert!(row.reel_max_segments.is_none());
}
}
+567
View File
@@ -0,0 +1,567 @@
//! Read-side views of the true-instant date contract (ADR-0001).
//!
//! [`crate::date_resolver`] is the ingest-side counterpart: it *writes* the
//! `(date_utc, camera_offset_minutes, date_taken_tz_source)` triple. This
//! module is what *reads* it. For a row — converted or not yet — it answers:
//!
//! 1. **What true instant does the row name?** — [`row_true_instant`].
//! Anchors time-window queries (SMS messages, location history, calendar
//! events) with zero offset arithmetic: the window is centered on the
//! instant, never on a wall clock.
//! 2. **What wall clock does the owner see?** — [`row_display_wall`].
//! The date insight prompts tell the model (camera wall clock when the
//! row carries an offset, display-zone rendering when it doesn't). Must
//! equal the wall the metadata endpoint reports, so prompt and UI agree.
//! 3. **Which owner day does the row land on?** — [`row_display_day`].
//! The grouping rule for memories buckets and reels: a photo taken in
//! Japan at 21:00 JST lands in the *owner's* day, not the camera's.
//!
//! Converted rows read the new triple directly. Unconverted rows (the
//! `backfill_utc_dates` drain has not reached them yet) fall back to the
//! legacy `date_taken` under its per-source convention (ADR-0001):
//!
//! | source | legacy holds |
//! |---|---|
//! | `exif` | camera wall clock, reinterpreted as UTC |
//! | `exiftool` (video) | the container's UTC-rendered naive, rendered in the server zone |
//! | `exiftool` (image) | camera wall clock, rendered in the server zone |
//! | `filename` (wall patterns) | wall clock, reinterpreted as UTC |
//! | `filename` (unix-timestamp) | a true instant |
//! | `fs_time` | a true instant |
//! | `manual` | the display-zone wall the operator picked, reinterpreted as UTC |
//!
//! so mixed rows mid-drain still group into the right day and the insight
//! prompts stay correct row-by-row as the drain converts.
use chrono::{NaiveDate, NaiveDateTime, TimeZone};
use chrono_tz::Tz;
use crate::camera_tz;
use crate::database::models::ImageExif;
/// Best-effort true instant (unix seconds) the row names.
///
/// `date_utc` when the drain has converted the row; otherwise derived from
/// the legacy value under its per-source convention. `server_zone` is the
/// fallback rung the drain would use without offset-tag or GPS evidence
/// (the camera was probably carried in the operator's zone); `display_zone`
/// interprets manual rows.
pub fn row_true_instant(row: &ImageExif, display_zone: Tz, server_zone: Option<Tz>) -> Option<i64> {
let legacy = row.date_taken?;
// Converted rows name their instant directly.
if let Some(utc) = row.date_utc {
return Some(utc);
}
match row.date_taken_source.as_deref() {
// Camera wall reinterpreted as UTC: the wall is the legacy value
// itself; the instant is that wall in the assumed zone.
Some("exif") => wall_in_zone(legacy, server_zone),
// exiftool rendered a naive string in the server zone. Videos hold
// the container's UTC-rendered creation time (a true instant behind
// one server-zone render); photos hold the camera wall.
Some("exiftool") => {
if crate::file_types::is_video_file(std::path::Path::new(&row.file_path)) {
// legacy = server.render(naive_utc) → recover naive_utc and
// read it back as UTC.
let naive = render_in_zone_fallback(legacy, server_zone);
Some(naive.and_utc().timestamp())
} else {
// legacy = server.render(wall); the instant is that wall in
// the server zone, which is `legacy` by construction.
Some(legacy)
}
}
// Filename class: wall patterns name a wall clock, the
// unix-timestamp pattern names a true instant directly.
Some("filename") => match crate::memories::match_filename_date(&row.file_path) {
Some(m) if matches!(m.date, crate::memories::FilenameDate::Wall(_)) => {
wall_in_zone(legacy, server_zone)
}
_ => Some(legacy),
},
Some("fs_time") => Some(legacy),
// The operator picked a display-zone wall; interpret it there.
Some("manual") => camera_tz::manual_date_resolution(Some(display_zone.name()), legacy)
.map(|(instant, _)| instant),
// Unknown / absent source: treat the legacy value as a true instant
// (the most common convention) rather than a wall clock.
_ => Some(legacy),
}
}
/// The wall clock the owner sees for this row — camera wall when the row
/// carries an offset (a Japan photo shows the Japan wall), display-zone
/// rendering when it doesn't. Unconverted rows use their per-source
/// convention (see module docs).
pub fn row_display_wall(
row: &ImageExif,
display_zone: Tz,
server_zone: Option<Tz>,
) -> Option<NaiveDateTime> {
if let Some(utc) = row.date_utc {
// Converted: wall = instant + camera offset (offset = local − UTC).
if let Some(offset_min) = row.camera_offset_minutes {
let wall = chrono::DateTime::from_timestamp(utc + offset_min as i64 * 60, 0)?;
return Some(wall.naive_utc());
}
return render_in_zone_opt(utc, display_zone);
}
let legacy = row.date_taken?;
match row.date_taken_source.as_deref() {
// The wall clock itself, stored as naive-as-UTC.
Some("exif") => Some(legacy_as_naive(legacy)),
Some("exiftool") => {
if crate::file_types::is_video_file(std::path::Path::new(&row.file_path)) {
// No camera wall exists for a container; show the owner's
// zone rendering of the recovered instant.
let naive = render_in_zone_fallback(legacy, server_zone);
let instant = naive.and_utc().timestamp();
render_in_zone_opt(instant, display_zone)
} else {
// The camera wall, recovered from the server-zone render.
Some(render_in_zone_fallback(legacy, server_zone))
}
}
Some("filename") => match crate::memories::match_filename_date(&row.file_path) {
Some(m) if matches!(m.date, crate::memories::FilenameDate::Wall(_)) => {
Some(legacy_as_naive(legacy))
}
_ => render_in_zone_opt(legacy, display_zone),
},
Some("fs_time") => render_in_zone_opt(legacy, display_zone),
Some("manual") => Some(legacy_as_naive(legacy)),
_ => render_in_zone_opt(legacy, display_zone),
}
}
/// The true instant a filename-class date names, for callers that fall
/// back to the filename when the row carries no date at all: wall patterns
/// are read in `server_zone` (the zone the camera was probably in),
/// unix-timestamp patterns are true instants outright.
pub fn filename_true_instant(
fd: &crate::memories::FilenameDate,
server_zone: Option<Tz>,
) -> Option<i64> {
match fd {
crate::memories::FilenameDate::Utc(dt) => Some(dt.timestamp()),
crate::memories::FilenameDate::Wall(wall) => match server_zone {
Some(z) => z
.from_local_datetime(wall)
.single()
.map(|dt| dt.timestamp()),
None => Some(wall.and_utc().timestamp()),
},
}
}
/// The owner's calendar day for this row: the day of the true instant in
/// the owner's display zone. This is the grouping key — a photo taken in
/// Japan at 21:00 JST lands in the owner's day, not the camera's.
pub fn row_display_day(
row: &ImageExif,
display_zone: Tz,
server_zone: Option<Tz>,
) -> Option<NaiveDate> {
let instant = row_true_instant(row, display_zone, server_zone)?;
Some(
display_zone
.timestamp_opt(instant, 0)
.single()?
.date_naive(),
)
}
/// Render a unix instant as a naive wall in `zone`.
fn render_in_zone_opt(instant: i64, zone: Tz) -> Option<NaiveDateTime> {
zone.timestamp_opt(instant, 0)
.single()
.map(|dt| dt.naive_local())
}
/// Render a unix instant as a naive wall in `zone`, falling back to the
/// naive-as-UTC reading when the zone is unknown.
fn render_in_zone_fallback(instant: i64, zone: Option<Tz>) -> NaiveDateTime {
match zone {
Some(z) => z
.timestamp_opt(instant, 0)
.single()
.map(|dt| dt.naive_local())
.unwrap_or_else(|| legacy_as_naive(instant)),
None => legacy_as_naive(instant),
}
}
/// Read a stored wall-as-UTC epoch back as its naive wall.
fn legacy_as_naive(legacy: i64) -> NaiveDateTime {
chrono::DateTime::from_timestamp(legacy, 0)
.expect("valid epoch")
.naive_utc()
}
/// The true instant of a wall clock assumed to have been read in `zone`;
/// naive-as-UTC when the zone is unknown.
fn wall_in_zone(legacy_wall_epoch: i64, zone: Option<Tz>) -> Option<i64> {
let wall = legacy_as_naive(legacy_wall_epoch);
match zone {
Some(z) => z
.from_local_datetime(&wall)
.single()
.map(|dt| dt.timestamp()),
None => Some(legacy_wall_epoch),
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{NaiveDate, TimeZone};
use chrono_tz::Tz;
fn ny() -> Tz {
Tz::America__New_York
}
/// Build a minimal `ImageExif` row for date-field tests.
fn row(
path: &str,
date_taken: Option<i64>,
source: Option<&str>,
date_utc: Option<i64>,
offset: Option<i32>,
) -> ImageExif {
ImageExif {
id: 1,
library_id: 1,
file_path: path.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,
created_time: 0,
last_modified: 0,
content_hash: None,
size_bytes: None,
phash_64: None,
dhash_64: None,
duplicate_of_hash: None,
duplicate_decided_at: None,
date_taken_source: source.map(str::to_string),
original_date_taken: None,
original_date_taken_source: None,
clip_embedding: None,
clip_model_version: None,
date_utc,
camera_offset_minutes: offset,
date_taken_tz_source: None,
original_date_utc: None,
original_camera_offset_minutes: None,
original_date_taken_tz_source: None,
}
}
fn utc_epoch(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> i64 {
NaiveDate::from_ymd_opt(y, mo, d)
.unwrap()
.and_hms_opt(h, mi, 0)
.unwrap()
.and_utc()
.timestamp()
}
/// The AC-3 fixture: three rows naming the same true instant
/// (2024-07-05 01:30 UTC = 2024-07-04 21:30 EDT — the owner's day is
/// July 4), one per source convention. Server zone = display zone = NY.
fn ac3_fixtures() -> (ImageExif, ImageExif, ImageExif, i64) {
let instant = utc_epoch(2024, 7, 5, 1, 30);
let exif = row(
"photos/IMG_1234.jpg",
Some(utc_epoch(2024, 7, 4, 21, 30)), // wall 21:30 as naive-as-UTC
Some("exif"),
None,
None,
);
let fs = row(
"photos/scan.pdf.jpg",
Some(instant),
Some("fs_time"),
None,
None,
);
// Container: exiftool rendered the UTC naive "2024-07-05 01:30" in
// the server zone (EDT) → instant + 4h.
let container = row(
"videos/clip.mp4",
Some(instant + 4 * 3600),
Some("exiftool"),
None,
None,
);
(exif, fs, container, instant)
}
#[test]
fn ac3_same_instant_three_sources_group_into_one_owner_day() {
let (exif, fs, container, instant) = ac3_fixtures();
let d = ny();
for (name, r) in [
("exif", &exif),
("fs_time", &fs),
("exiftool-video", &container),
] {
assert_eq!(
row_true_instant(r, d, Some(d)),
Some(instant),
"{name}: wrong instant"
);
assert_eq!(
row_display_day(r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-04".to_string()),
"{name}: wrong owner day"
);
}
}
#[test]
fn ac3_old_client_offset_grouping_would_have_misplaced_the_container() {
// Regression guard for the double-shift: rendering the container's
// legacy value in the client (NY) offset lands on July 5, a day
// after the owner's.
let (_, _, container, _) = ac3_fixtures();
let legacy = container.date_taken.unwrap();
let old_grouping_day = chrono::FixedOffset::east_opt(-4 * 3600)
.unwrap()
.timestamp_opt(legacy, 0)
.single()
.unwrap()
.date_naive();
assert_eq!(old_grouping_day.to_string(), "2024-07-05");
}
#[test]
fn converted_row_with_offset_shows_camera_wall() {
// Japan (UTC+9) photo: instant 2024-07-05 12:00 UTC, wall 21:00 JST.
let r = row(
"japan/IMG_0001.jpg",
Some(utc_epoch(2024, 7, 5, 21, 0)),
Some("exif"),
Some(utc_epoch(2024, 7, 5, 12, 0)),
Some(9 * 60),
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-07-05 21:00:00".to_string())
);
// ...but groups into the owner's (NY) day: 12:00 UTC = 08:00 EDT.
assert_eq!(
row_display_day(&r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-05".to_string())
);
assert_eq!(
row_true_instant(&r, d, Some(d)),
Some(utc_epoch(2024, 7, 5, 12, 0))
);
}
#[test]
fn converted_row_without_offset_renders_in_display_zone() {
// fs_time row, no camera offset: wall = display-zone rendering.
// 2024-01-15 04:30 UTC = 23:30 Jan 14 EST (DST-aware).
let r = row(
"fs/IMG_9.jpg",
Some(utc_epoch(2024, 1, 15, 4, 30)),
Some("fs_time"),
Some(utc_epoch(2024, 1, 15, 4, 30)),
None,
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-01-14 23:30:00".to_string())
);
assert_eq!(
row_display_day(&r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-01-14".to_string())
);
}
#[test]
fn unconverted_exif_wall_is_the_legacy_naive_and_instant_uses_server_zone() {
// Wall 2024-01-15 21:30 (EST era), legacy = 21:30 naive-as-UTC.
// Instant = 21:30 EST = 2024-01-16 02:30 UTC (DST-aware, not +4h).
let r = row(
"photo.jpg",
Some(utc_epoch(2024, 1, 15, 21, 30)),
Some("exif"),
None,
None,
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-01-15 21:30:00".to_string())
);
assert_eq!(
row_true_instant(&r, d, Some(d)),
Some(utc_epoch(2024, 1, 16, 2, 30))
);
assert_eq!(
row_display_day(&r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-01-15".to_string())
);
}
#[test]
fn unconverted_exiftool_photo_wall_is_server_zone_render() {
// Camera wall 2024-07-04 12:00 rendered in server zone EDT →
// legacy = 16:00 UTC. The wall comes back out the other side.
let r = row(
"raw/IMG_5.NEF",
Some(utc_epoch(2024, 7, 4, 16, 0)),
Some("exiftool"),
None,
None,
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-07-04 12:00:00".to_string())
);
// Instant = that wall in the server zone = the legacy value itself.
assert_eq!(
row_true_instant(&r, d, Some(d)),
Some(utc_epoch(2024, 7, 4, 16, 0))
);
}
#[test]
fn unconverted_manual_row_interprets_wall_in_display_zone() {
// Operator picked 2024-07-04 21:30 in their display zone (NY/EDT):
// legacy = 21:30 naive-as-UTC, instant = 01:30 UTC July 5.
let r = row(
"manual.jpg",
Some(utc_epoch(2024, 7, 4, 21, 30)),
Some("manual"),
None,
None,
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-07-04 21:30:00".to_string())
);
assert_eq!(
row_true_instant(&r, d, Some(d)),
Some(utc_epoch(2024, 7, 5, 1, 30))
);
assert_eq!(
row_display_day(&r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-04".to_string())
);
}
#[test]
fn unconverted_filename_wall_and_utc_patterns_diverge() {
// Wall pattern (screenshot): legacy = wall as naive-as-UTC.
let wall = row(
"Screenshot_2024-07-04-21-30-00.png",
Some(utc_epoch(2024, 7, 4, 21, 30)),
Some("filename"),
None,
None,
);
// Unix-timestamp pattern: legacy is the true instant itself.
let ts = row(
"1720128600.jpg",
Some(utc_epoch(2024, 7, 5, 1, 30)),
Some("filename"),
None,
None,
);
let d = ny();
assert_eq!(
row_true_instant(&wall, d, Some(d)),
Some(utc_epoch(2024, 7, 5, 1, 30)),
"wall 21:30 EDT → 01:30 UTC"
);
assert_eq!(
row_true_instant(&ts, d, Some(d)),
Some(utc_epoch(2024, 7, 5, 1, 30)),
"timestamp pattern is already the instant"
);
// Both land on the owner's July 4.
for r in [&wall, &ts] {
assert_eq!(
row_display_day(r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-04".to_string())
);
}
}
#[test]
fn display_day_is_dst_correct_across_the_year() {
// Same UTC instant, winter vs summer: the owner's day differs by
// the DST shift. 04:30 UTC = 23:30 previous day EST, 00:30 same
// day EDT.
let winter = row(
"w.jpg",
Some(utc_epoch(2024, 1, 15, 4, 30)),
Some("fs_time"),
Some(utc_epoch(2024, 1, 15, 4, 30)),
None,
);
let summer = row(
"s.jpg",
Some(utc_epoch(2024, 7, 15, 4, 30)),
Some("fs_time"),
Some(utc_epoch(2024, 7, 15, 4, 30)),
None,
);
let d = ny();
assert_eq!(
row_display_day(&winter, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-01-14".to_string())
);
assert_eq!(
row_display_day(&summer, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-15".to_string())
);
}
#[test]
fn display_day_respects_a_non_new_york_display_zone() {
// Owner in Tokyo: 2024-07-05 01:30 UTC = 10:30 JST July 5 (not
// the July 4 a NY owner sees).
let (_, fs, _, instant) = ac3_fixtures();
let tokyo = Tz::Asia__Tokyo;
assert_eq!(
row_display_day(&fs, tokyo, Some(ny())).map(|dt| dt.to_string()),
Some("2024-07-05".to_string())
);
assert_eq!(row_true_instant(&fs, tokyo, Some(ny())), Some(instant));
}
}
+570
View File
@@ -0,0 +1,570 @@
//! Pre-cutover audit utility for the filename date class (ticket #117).
//!
//! A one-shot, operator-run report listing every file that matches a
//! filename-class pattern: the file's current stored date + source and
//! the date / source / offset-source the re-based resolver would assign
//! it. It reads the live database and walks the libraries (honoring the
//! library exclusions and the online gate — offline libraries are
//! reported as skipped, never processed). It changes nothing: strictly
//! read-only, no writes to any table.
//!
//! Purpose: the filename class is a documented inversion of EXIF-wins
//! and depends on the operator's memory of "what these filenames mean".
//! This report is the human spot-check that gates the production
//! backfill drain — the operator reviews it, fixes any mis-tagged
//! pattern or misclassified file, and only then starts the drain.
//!
//! The proposed values mirror the drain's inputs: the resolver runs
//! with empty priors, exactly as the per-tick drain calls
//! `resolve_dates_batch` today. In-class files never touch exiftool, so
//! the audit spawns no subprocesses at all.
use std::collections::HashMap;
use std::path::PathBuf;
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use crate::database::schema::image_exif;
use crate::date_resolver::ResolvedDate;
use crate::libraries::{Library, LibraryHealth, probe_online};
use crate::memories::{PathExcluder, match_filename_date};
/// The stored date contract for one `image_exif` row: the legacy pair
/// plus the new-contract audit value (NULL until the drain converts it).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredDate {
pub date_taken: Option<i64>,
pub date_taken_source: Option<String>,
pub date_taken_tz_source: Option<String>,
}
/// One report row: a filename-class file, what the database has for it
/// now, and what the re-based resolver would assign.
#[derive(Clone, Debug)]
pub struct AuditRow {
pub library_id: i32,
pub library_name: String,
/// Root-relative path, slash-separated.
pub rel_path: String,
/// Which filename-class pattern matched (`screenshot`, `dash`, …).
pub pattern: &'static str,
/// The row's stored contract; `None` when the file is not indexed
/// yet (no `image_exif` row).
pub current: Option<StoredDate>,
/// What the re-based resolver would assign (legacy value, source,
/// true instant, offset, offset-source).
pub proposed: ResolvedDate,
/// True when a stored row exists and its legacy `date_taken`
/// disagrees with the proposed legacy value — the rows the operator
/// needs to spot-check hardest.
pub changed: bool,
}
/// A library the audit did not process, with the reason. Disabled
/// (operator kill switch) and offline (probe failed) are reported,
/// never partially processed.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SkippedLibrary {
pub library_id: i32,
pub name: String,
pub reason: String,
}
/// One `image_exif` row's date contract, as loaded for the audit.
#[derive(diesel::Queryable)]
struct StoredDateRow {
rel_path: String,
date_taken: Option<i64>,
date_taken_source: Option<String>,
date_taken_tz_source: Option<String>,
}
/// Load `rel_path -> stored date fields` for one library. Read-only
/// (SELECTs only; `&mut` is diesel's connection contract, not a write).
pub fn stored_dates_for_library(
conn: &mut SqliteConnection,
library_id: i32,
) -> Result<HashMap<String, StoredDate>, diesel::result::Error> {
let rows: Vec<StoredDateRow> = image_exif::table
.filter(image_exif::library_id.eq(library_id))
.select((
image_exif::rel_path,
image_exif::date_taken,
image_exif::date_taken_source,
image_exif::date_taken_tz_source,
))
.load(conn)?;
Ok(rows
.into_iter()
.map(
|StoredDateRow {
rel_path,
date_taken,
date_taken_source,
date_taken_tz_source,
}| {
(
rel_path,
StoredDate {
date_taken,
date_taken_source,
date_taken_tz_source,
},
)
},
)
.collect())
}
/// Audit one online library: walk its root (honoring the effective
/// excludes), match the filename class, and compute the proposed
/// resolution for every in-class file in a single resolver pass. Rows
/// are stable-sorted by `rel_path`. `stored` comes from
/// [`stored_dates_for_library`].
pub fn audit_library(
library: &Library,
global_excludes: &[String],
stored: &HashMap<String, StoredDate>,
) -> Vec<AuditRow> {
let root = PathBuf::from(&library.root_path);
let excluder = PathExcluder::new(&root, &library.effective_excluded_dirs(global_excludes));
let mut matched: Vec<(String, PathBuf, &'static str)> = Vec::new();
// Depth 0 (the root itself) is always allowed so a pathological
// exclude matching the root can't silently yield an empty report —
// same guard as file_scan::walk_library_files.
for entry in walkdir::WalkDir::new(&root)
.into_iter()
.filter_entry(|e| e.depth() == 0 || !excluder.is_excluded(e.path()))
.flatten()
{
if !entry.file_type().is_file() {
continue;
}
let Some(rel) = entry
.path()
.strip_prefix(&root)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))
else {
continue;
};
let Some(fm) = match_filename_date(entry.file_name().to_str().unwrap_or("")) else {
continue;
};
matched.push((rel, entry.path().to_path_buf(), fm.pattern));
}
matched.sort_by(|a, b| a.0.cmp(&b.0));
// One resolver pass for the whole library. In-class files resolve
// from the name alone, so this spawns no exiftool subprocess.
let paths: Vec<PathBuf> = matched.iter().map(|(_, p, _)| p.clone()).collect();
let resolved = crate::date_resolver::resolve_dates_batch(&paths, &HashMap::new());
matched
.into_iter()
.map(|(rel_path, abs, pattern)| {
// An in-class name always resolves (the class never fails to
// date a file it previously dated); `None` would be a bug in
// the resolver, not a missing file.
let proposed = resolved
.get(&abs)
.copied()
.expect("in-class filename must resolve without exiftool");
let current = stored.get(&rel_path).cloned();
let changed = current
.as_ref()
.map(|c| c.date_taken != Some(proposed.timestamp))
.unwrap_or(false);
AuditRow {
library_id: library.id,
library_name: library.name.clone(),
rel_path,
pattern,
current,
proposed,
changed,
}
})
.collect()
}
/// Run the audit over every configured library: disabled libraries are
/// reported as skipped; online libraries are walked; probe-failed
/// libraries are reported as skipped (never partially processed). The
/// result is stable-sorted by library id, then `rel_path`, so two runs
/// diff cleanly.
pub fn run_audit(
conn: &mut SqliteConnection,
libraries: Vec<Library>,
global_excludes: &[String],
) -> (Vec<AuditRow>, Vec<SkippedLibrary>) {
let mut rows: Vec<AuditRow> = Vec::new();
let mut skipped: Vec<SkippedLibrary> = Vec::new();
for lib in libraries {
if !lib.enabled {
skipped.push(SkippedLibrary {
library_id: lib.id,
name: lib.name.clone(),
reason: "disabled (enabled=false)".to_string(),
});
continue;
}
// Same online gate as the watcher: probe with prior knowledge
// (had_data) from the image_exif row count.
let had_data: i64 = image_exif::table
.filter(image_exif::library_id.eq(lib.id))
.count()
.get_result(conn)
.unwrap_or(0);
match probe_online(&lib, had_data > 0) {
LibraryHealth::Online => {
let stored = stored_dates_for_library(conn, lib.id).unwrap_or_default();
rows.extend(audit_library(&lib, global_excludes, &stored));
}
LibraryHealth::Stale { reason, .. } => {
skipped.push(SkippedLibrary {
library_id: lib.id,
name: lib.name.clone(),
reason: format!("offline ({reason})"),
});
}
}
}
rows.sort_by(|a, b| {
(a.library_id, a.rel_path.as_str()).cmp(&(b.library_id, b.rel_path.as_str()))
});
(rows, skipped)
}
fn format_epoch_utc(ts: Option<i64>) -> String {
match ts.and_then(|t| chrono::DateTime::<chrono::Utc>::from_timestamp(t, 0)) {
Some(dt) => dt.format("%Y-%m-%d %H:%M:%S").to_string(),
None => "-".to_string(),
}
}
/// Render the report. One entry per in-class file — three lines each:
/// header, `current:`, `proposed:` — stable-sorted, with a `*` on rows
/// whose stored legacy date would change; then the skipped libraries
/// and a summary. Deterministic: two runs diff cleanly.
pub fn format_report(rows: &[AuditRow], skipped: &[SkippedLibrary]) -> String {
let mut out = String::new();
for row in rows {
let current = match &row.current {
Some(c) => format!(
"{} [{}]",
format_epoch_utc(c.date_taken),
c.date_taken_source.as_deref().unwrap_or("-")
),
None => "unindexed".to_string(),
};
let proposed_tz = row
.proposed
.tz_source
.map(|t| t.as_str().to_string())
.unwrap_or_else(|| "-".into());
// The numeric offset (minutes ahead of UTC) is the value the
// operator actually spot-checks: a wall pattern resolved with
// empty evidence carries the server zone's offset for that date.
let proposed_offset = row
.proposed
.camera_offset_minutes
.map(|m| format!("{m:+}m"))
.unwrap_or_else(|| "-".into());
out.push_str(&format!(
"{}\t{:<19}\t{:<20}\tlib={} {}\n current: {}\n proposed: {} [{}] tz_source={} offset={}\n",
if row.changed { "*" } else { " " },
row.pattern,
row.rel_path,
row.library_id,
row.library_name,
current,
format_epoch_utc(Some(row.proposed.timestamp)),
row.proposed.source.as_str(),
proposed_tz,
proposed_offset,
));
}
if !skipped.is_empty() {
out.push_str("\nskipped libraries:\n");
for s in skipped {
out.push_str(&format!(
" lib={} {} — {}\n",
s.library_id, s.name, s.reason
));
}
}
out.push_str(&format!(
"\n{} file(s) in the filename class, {} would change, {} library(ies) skipped\n",
rows.len(),
rows.iter().filter(|r| r.changed).count(),
skipped.len()
));
out
}
#[cfg(test)]
mod tests {
use super::*;
use diesel::Connection;
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
use std::fs;
use tempfile::tempdir;
const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!();
fn migrated_conn() -> SqliteConnection {
let mut conn = SqliteConnection::establish(":memory:").expect("in-memory sqlite");
conn.run_pending_migrations(DB_MIGRATIONS)
.expect("migrations");
conn
}
fn seed_row(
conn: &mut SqliteConnection,
library_id: i32,
rel_path: &str,
date_taken: Option<i64>,
source: Option<&str>,
) {
diesel::insert_into(image_exif::table)
.values((
image_exif::library_id.eq(library_id),
image_exif::rel_path.eq(rel_path),
image_exif::date_taken.eq(date_taken),
image_exif::date_taken_source.eq(source),
image_exif::created_time.eq(1_700_000_000),
image_exif::last_modified.eq(1_700_000_000),
))
.execute(conn)
.expect("seed row");
}
fn library(id: i32, name: &str, root: &std::path::Path, enabled: bool) -> Library {
Library {
id,
name: name.to_string(),
root_path: root.to_string_lossy().to_string(),
enabled,
excluded_dirs: Vec::new(),
}
}
/// Fixture library seeded with mixed conventions:
/// - `Screenshot_2014-06-01-20-44-50.png` — in-class wall, stored row
/// says exif/2021 (the inversion the operator must spot-check).
/// - `att_1422489664680106.jpeg` — in-class unix timestamp, stored
/// row already agrees.
/// - `2015-01-09_02-15-15.jpg` — in-class wall, not indexed yet.
/// - `plain_photo.jpg` — out of class, must not appear.
/// - `@eaDir/2016-02-02_03-03-03.jpg` — excluded dir, must not appear.
fn fixture_library(root: &std::path::Path) {
for path in [
"Screenshot_2014-06-01-20-44-50.png",
"att_1422489664680106.jpeg",
"2015-01-09_02-15-15.jpg",
"plain_photo.jpg",
"@eaDir/2016-02-02_03-03-03.jpg",
] {
let full = root.join(path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&full, b"fixture").unwrap();
}
}
#[test]
fn audit_shows_inversion_timestamp_and_exclusions() {
let tmp = tempdir().unwrap();
fixture_library(tmp.path());
let mut conn = migrated_conn();
// 2021-01-15 12:00:00 UTC — a stale EXIF date the filename
// outranks. att_1422489664680106 -> first 10 digits =
// 1422489664 = 2015-01-29 07:21:04 UTC.
let stale_exif: i64 = 1_610_742_400;
seed_row(
&mut conn,
1,
"Screenshot_2014-06-01-20-44-50.png",
Some(stale_exif),
Some("exif"),
);
seed_row(
&mut conn,
1,
"att_1422489664680106.jpeg",
Some(1422489664),
Some("filename"),
);
let lib = library(1, "photos", tmp.path(), true);
let stored = stored_dates_for_library(&mut conn, 1).unwrap();
// Global excludes as the EXCLUDED_DIRS env var would provide in
// production (junk dirs like @eaDir never enter the class walk).
let rows = audit_library(&lib, &["@eaDir".to_string()], &stored);
// Exactly the three in-class files; out-of-class and excluded
// paths are absent.
let rels: Vec<&str> = rows.iter().map(|r| r.rel_path.as_str()).collect();
assert_eq!(
rels,
vec![
"2015-01-09_02-15-15.jpg",
"Screenshot_2014-06-01-20-44-50.png",
"att_1422489664680106.jpeg"
]
);
// The inversion: stored exif/2021, proposed filename/2014 wall
// (wall-as-UTC legacy), tz_source assumed_server (empty priors,
// mirroring the drain).
let inv = rows
.iter()
.find(|r| r.rel_path == "Screenshot_2014-06-01-20-44-50.png")
.unwrap();
assert_eq!(inv.pattern, "screenshot");
assert_eq!(inv.current.as_ref().unwrap().date_taken, Some(stale_exif));
assert_eq!(
inv.current.as_ref().unwrap().date_taken_source.as_deref(),
Some("exif")
);
assert_eq!(inv.proposed.source.as_str(), "filename");
assert_eq!(
inv.proposed.timestamp,
1401655490 // 2014-06-01 20:44:50 as UTC (wall-as-UTC legacy)
);
assert_eq!(
inv.proposed.tz_source.map(|t| t.as_str()),
Some("assumed_server")
);
// Server zone America/New_York is EDT (UTC-4) in June.
assert_eq!(inv.proposed.camera_offset_minutes, Some(-240));
assert!(inv.changed, "stored exif date must flag as changed");
// The unix timestamp: utc_direct, no offset, and the stored row
// already agrees -> not flagged.
let ts = rows
.iter()
.find(|r| r.rel_path == "att_1422489664680106.jpeg")
.unwrap();
assert_eq!(ts.pattern, "unix_timestamp");
assert_eq!(ts.proposed.source.as_str(), "filename");
assert_eq!(ts.proposed.timestamp, 1422489664);
assert_eq!(ts.proposed.date_utc, Some(1422489664));
assert_eq!(ts.proposed.camera_offset_minutes, None);
assert_eq!(
ts.proposed.tz_source.map(|t| t.as_str()),
Some("utc_direct")
);
assert!(!ts.changed);
// Unindexed in-class file: current is None, not flagged.
let unidx = rows
.iter()
.find(|r| r.rel_path == "2015-01-09_02-15-15.jpg")
.unwrap();
assert_eq!(unidx.pattern, "dash");
assert!(unidx.current.is_none());
assert!(!unidx.changed);
}
#[test]
fn run_audit_skips_disabled_and_offline_libraries() {
let tmp = tempdir().unwrap();
let online_root = tmp.path().join("online");
let missing_root = tmp.path().join("missing");
fs::create_dir_all(&online_root).unwrap();
fixture_library(&online_root);
// missing_root is never created -> probe fails.
let mut conn = migrated_conn();
// Give the offline library a row so had_data=true (the strict
// probe: empty dir with rows is suspicious).
seed_row(&mut conn, 2, "whatever.jpg", Some(1), Some("exif"));
let libs = vec![
library(2, "offline", &missing_root, true),
library(1, "online", &online_root, true),
library(3, "disabled", &online_root, false),
];
let (rows, skipped) = run_audit(&mut conn, libs, &[]);
let skipped_ids: Vec<i32> = skipped.iter().map(|s| s.library_id).collect();
assert_eq!(skipped_ids, vec![2, 3]);
assert!(skipped[0].reason.starts_with("offline"));
assert_eq!(skipped[1].reason, "disabled (enabled=false)");
// Only the online library was processed.
let libs_seen: Vec<i32> = rows.iter().map(|r| r.library_id).collect();
assert!(libs_seen.iter().all(|id| *id == 1));
assert!(!rows.is_empty());
}
#[test]
fn run_audit_sorts_by_library_then_path() {
let tmp = tempdir().unwrap();
let a = tmp.path().join("a");
let b = tmp.path().join("b");
fs::create_dir_all(&a).unwrap();
fs::create_dir_all(&b).unwrap();
fs::write(a.join("2015-02-02_02-02-02.jpg"), b"").unwrap();
fs::write(a.join("Screenshot_2014-06-01-20-44-50.png"), b"").unwrap();
fs::write(b.join("1401638400.jpeg"), b"").unwrap();
// Intentionally pass libraries out of order.
let libs = vec![library(2, "b", &b, true), library(1, "a", &a, true)];
let mut conn = migrated_conn();
let (rows, skipped) = run_audit(&mut conn, libs, &[]);
assert!(skipped.is_empty());
let got: Vec<(i32, String)> = rows
.iter()
.map(|r| (r.library_id, r.rel_path.clone()))
.collect();
assert_eq!(
got,
vec![
(1, "2015-02-02_02-02-02.jpg".to_string()),
(1, "Screenshot_2014-06-01-20-44-50.png".to_string()),
(2, "1401638400.jpeg".to_string()),
]
);
}
#[test]
fn format_report_is_deterministic_and_marks_changed() {
let tmp = tempdir().unwrap();
fixture_library(tmp.path());
let mut conn = migrated_conn();
seed_row(
&mut conn,
1,
"Screenshot_2014-06-01-20-44-50.png",
Some(1_610_742_400),
Some("exif"),
);
let lib = library(1, "photos", tmp.path(), true);
let (rows, skipped) = run_audit(&mut conn, vec![lib], &["@eaDir".to_string()]);
let first = format_report(&rows, &skipped);
let second = format_report(&rows, &skipped);
assert_eq!(first, second);
assert!(first.contains("*\t"), "changed row must be flagged");
assert!(first.contains("screenshot"));
assert!(first.contains("utc_direct"));
// Numeric offset is printed: the wall row carries the server-zone
// offset for that date, timestamp rows have none.
assert!(first.contains("offset=-240m"));
assert!(first.contains("offset=-\n"));
// The stored source renders without debug quotes (P1 from
// review): `2021-... [exif]`, never `["exif"]`.
assert!(first.contains("[exif]"));
assert!(!first.contains("\"exif\""));
assert!(first.contains("3 file(s) in the filename class, 1 would change"));
// Excluded path must not appear anywhere in the report.
assert!(!first.contains("2016-02-02_03-03-03.jpg"));
}
}
+36 -11
View File
@@ -1142,12 +1142,25 @@ pub async fn get_gps_summary(
Ok(gps_data) => {
let mut photos: Vec<GpsPhotoSummary> = gps_data
.into_iter()
.map(|(path, lat, lon, date_taken)| GpsPhotoSummary {
path,
lat,
lon,
date_taken,
})
.map(
|(
path,
lat,
lon,
date_taken,
date_utc,
camera_offset_minutes,
date_taken_tz_source,
)| GpsPhotoSummary {
path,
lat,
lon,
date_taken,
date_utc,
camera_offset_minutes,
date_taken_tz_source,
},
)
.collect();
// Sort by date_taken based on request, defaulting to ascending (oldest to newest)
@@ -1695,7 +1708,18 @@ mod tests {
_context: &opentelemetry::Context,
_base_path: &str,
_recursive: bool,
) -> Result<Vec<(String, f64, f64, Option<i64>)>, DbError> {
) -> Result<
Vec<(
String,
f64,
f64,
Option<i64>,
Option<i64>,
Option<i32>,
Option<String>,
)>,
DbError,
> {
todo!()
}
@@ -1817,10 +1841,11 @@ mod tests {
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
_span_token: &str,
_years_back: i32,
_tz_offset_minutes: i32,
) -> Result<Vec<(String, i64, i64)>, DbError> {
_conv_lo: i64,
_conv_hi: i64,
_unconv_lo: i64,
_unconv_hi: i64,
) -> Result<Vec<crate::database::models::ImageExif>, DbError> {
Ok(Vec::new())
}
+2 -1
View File
@@ -859,7 +859,8 @@ async fn synthesize_merge<D: KnowledgeDao + 'static>(
let ollama = app_state.ollama.clone();
let model_used = ollama.primary_model.clone();
let proposed = match ollama.generate(&prompt, Some(system)).await {
let cx = opentelemetry::Context::current();
let proposed = match ollama.generate(&cx, &prompt, Some(system)).await {
Ok(out) => {
// Strip the framing models reach for even with explicit
// "no preamble" guidance: leading "Merged description:"
+3
View File
@@ -14,6 +14,7 @@ pub mod content_hash;
pub mod data;
pub mod database;
pub mod date_resolver;
pub mod date_view;
pub mod duplicates;
pub mod error;
pub mod exif;
@@ -21,6 +22,7 @@ pub mod face_watch;
pub mod faces;
pub mod file_scan;
pub mod file_types;
pub mod filename_audit;
pub mod files;
pub mod geo;
pub mod libraries;
@@ -30,6 +32,7 @@ pub mod otel;
pub mod parsers;
pub mod perceptual_hash;
pub mod personas;
pub mod prefs;
pub mod service;
pub mod state;
pub mod tags;
+8
View File
@@ -41,6 +41,7 @@ mod content_hash;
mod data;
mod database;
mod date_resolver;
mod date_view;
mod duplicates;
mod error;
mod exif;
@@ -67,6 +68,7 @@ mod knowledge;
mod memories;
mod otel;
mod personas;
mod prefs;
mod reels;
mod service;
#[cfg(test)]
@@ -373,6 +375,12 @@ fn main() -> std::io::Result<()> {
.service(reels::reel_video_handler)
.service(reels::precomputed_reel_handler)
.service(reels::precomputed_video_handler)
.service(
web::resource("/prefs")
.route(web::get().to(prefs::get_prefs))
.route(web::put().to(prefs::put_prefs)),
)
.service(web::resource("/timezones").route(web::get().to(prefs::list_timezones)))
.service(ai::generate_insight_handler)
.service(ai::generate_agentic_insight_handler)
.service(ai::generation_status_handler)
+565 -61
View File
@@ -1,12 +1,13 @@
use actix_web::web::Data;
use actix_web::{HttpRequest, HttpResponse, Responder, get, web};
use chrono::{DateTime, FixedOffset, Local, NaiveDate, NaiveDateTime, Utc};
use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, TimeZone, Utc};
use log::{debug, trace, warn};
use opentelemetry::KeyValue;
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Mutex;
use crate::data::Claims;
@@ -107,10 +108,38 @@ pub enum MemoriesSpan {
Month,
}
/// Wire/storage string for the span (`"day"` | `"week"` | `"month"`) — the
/// same lowercase names as the serde rename, so a stored pref round-trips
/// through `MemoriesRequest.span` unchanged.
impl std::fmt::Display for MemoriesSpan {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
MemoriesSpan::Day => "day",
MemoriesSpan::Week => "week",
MemoriesSpan::Month => "month",
})
}
}
impl std::str::FromStr for MemoriesSpan {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"day" => Ok(MemoriesSpan::Day),
"week" => Ok(MemoriesSpan::Week),
"month" => Ok(MemoriesSpan::Month),
other => Err(format!("invalid span {other:?} (expected day|week|month)")),
}
}
}
#[derive(Deserialize)]
pub struct MemoriesRequest {
pub span: Option<MemoriesSpan>,
/// Client timezone offset in minutes from UTC (e.g., -480 for PST, 60 for CET)
/// Client timezone offset in minutes from UTC (e.g., -480 for PST, 60 for
/// CET). Accepted on the wire for old clients but no longer drives
/// grouping (ADR-0001) — grouping uses the owner's display zone.
#[allow(dead_code)]
pub timezone_offset_minutes: Option<i32>,
/// Optional library filter. Accepts a library id (e.g. "1") or name
/// (e.g. "main"). When omitted, results span all libraries.
@@ -120,11 +149,21 @@ pub struct MemoriesRequest {
#[derive(Debug, Serialize, Clone)]
pub struct MemoryItem {
pub path: String,
/// True instant (unix seconds) the memory names (ADR-0001) — the
/// grouping anchor. For unconverted rows this is *derived* from the
/// legacy value under its per-source convention, so it is the real
/// moment even while the `backfill_utc_dates` drain is still running.
pub created: Option<i64>,
pub modified: Option<i64>,
/// Id of the library this memory belongs to. Allows clients to show a
/// per-item source badge in union mode.
pub library_id: i32,
/// New-contract triple (ADR-0001). `date_utc` is `None` until the
/// `backfill_utc_dates` drain has converted the row; clients should
/// prefer it over `created` when present.
pub date_utc: Option<i64>,
pub camera_offset_minutes: Option<i32>,
pub date_taken_tz_source: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -132,6 +171,50 @@ pub struct MemoriesResponse {
pub items: Vec<MemoryItem>,
}
/// Epoch windows for the memories SQL pre-filter (ADR-0001).
///
/// The span feature matches the same calendar position (month-day /
/// week-of-year / month) across every past year, so the pre-filter must
/// cover the FULL years-back window — not just the current span window —
/// or past-year rows are dropped before [`day_in_span`] gets to match
/// them. Converted rows are tested on `date_utc` (the true instant);
/// unconverted rows on legacy `date_taken`, which sits up to ~15 h off
/// the instant under the per-source conventions (wall↔instant confusion
/// ≤ 12 h, zone range ≤ 2 h, DST margin 1 h) — hence the ±15 h band.
/// By construction the band subsumes the converted window, so the DAO's
/// single outer bound on `date_taken` never drops a converted row.
pub fn memories_span_windows(now_ts: i64, years_back: i64) -> (i64, i64, i64, i64) {
let years_lo = now_ts - years_back * 365 * 86_400;
(years_lo, now_ts, years_lo - 15 * 3600, now_ts + 15 * 3600)
}
/// True if `display_day` falls in the span (day/week/month) containing
/// `today` — both already in the owner's display zone. Mirrors the legacy
/// SQLite `strftime('%m-%d' | '%W' | '%m')` equality: weeks are
/// Monday-based and the days before the year's first Monday are week 00.
pub fn day_in_span(display_day: NaiveDate, today: NaiveDate, span: MemoriesSpan) -> bool {
match span {
MemoriesSpan::Day => {
display_day.month() == today.month() && display_day.day() == today.day()
}
MemoriesSpan::Week => sqlite_w_week(display_day) == sqlite_w_week(today),
MemoriesSpan::Month => display_day.month() == today.month(),
}
}
/// SQLite's `%W`: Monday-based week-of-year, `0` for the days before the
/// year's first Monday, `1` starting on the first Monday.
fn sqlite_w_week(d: NaiveDate) -> i32 {
let jan1 = NaiveDate::from_ymd_opt(d.year(), 1, 1).expect("Jan 1 exists");
let days_in_week0 = (7 - jan1.weekday().num_days_from_monday() as i64) % 7;
let days = (d - jan1).num_days();
if days < days_in_week0 {
0
} else {
((days - days_in_week0) / 7 + 1) as i32
}
}
/// A date carried by a filename, with its convention (ticket #116).
///
/// The two families of pattern make fundamentally different claims:
@@ -146,7 +229,7 @@ pub struct MemoriesResponse {
/// class**: the filename is the original date of record and outranks
/// EXIF / container dates (ADR-0001). Files whose name matches nothing
/// are out of the class and keep the global EXIF-wins waterfall.
#[derive(Copy, Clone, Debug, PartialEq)]
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum FilenameDate {
/// Wall clock the device displayed (screenshot / dash / compact).
Wall(NaiveDateTime),
@@ -174,12 +257,26 @@ impl FilenameDate {
}
}
pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
/// A filename that matched one of the filename-class patterns (#116):
/// which pattern fired and the date it carries.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct FilenameMatch {
/// Stable pattern name for audit / debug surfaces: `screenshot`,
/// `screenshot_compact`, `dash`, `dash_compact`, `compact`,
/// `unix_timestamp`.
pub pattern: &'static str,
pub date: FilenameDate,
}
/// Match a filename against the filename-class patterns, reporting which
/// pattern fired. This is the canonical matcher; `extract_date_from_filename`
/// is the date-only projection of it.
pub fn match_filename_date(filename: &str) -> Option<FilenameMatch> {
// The wall-clock patterns (screenshot / dash / compact) carry the
// device's own clock; the unix-timestamp pattern carries a true
// instant. `legacy_datetime()` reproduces the pre-migration
// interpretation of each (naive-as-UTC / epoch) for the legacy
// column and its consumers.
// instant. `FilenameDate::legacy_datetime()` reproduces the
// pre-migration interpretation of each (naive-as-UTC / epoch) for the
// legacy column and its consumers.
let build_wall_from_ymd_capture = |captures: &regex::Captures| -> Option<NaiveDateTime> {
let year = captures.get(1)?.as_str().parse::<i32>().ok()?;
let month = captures.get(2)?.as_str().parse::<u32>().ok()?;
@@ -197,7 +294,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
.captures(filename)
.and_then(|c| build_wall_from_ymd_capture(&c))
{
return Some(FilenameDate::Wall(naive));
return Some(FilenameMatch {
pattern: "screenshot",
date: FilenameDate::Wall(naive),
});
}
// Screenshot format: Screenshot_20140601[_-]204450.png
@@ -206,7 +306,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
.captures(filename)
.and_then(|c| build_wall_from_ymd_capture(&c))
{
return Some(FilenameDate::Wall(naive));
return Some(FilenameMatch {
pattern: "screenshot_compact",
date: FilenameDate::Wall(naive),
});
}
// 2. Dash format: 2015-01-09_02-15-15.jpg
@@ -215,7 +318,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
.captures(filename)
.and_then(|c| build_wall_from_ymd_capture(&c))
{
return Some(FilenameDate::Wall(naive));
return Some(FilenameMatch {
pattern: "dash",
date: FilenameDate::Wall(naive),
});
}
// Dash with compact time format: 2015-01-09-021515.jpg
@@ -224,7 +330,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
.captures(filename)
.and_then(|c| build_wall_from_ymd_capture(&c))
{
return Some(FilenameDate::Wall(naive));
return Some(FilenameMatch {
pattern: "dash_compact",
date: FilenameDate::Wall(naive),
});
}
// 3. Compact format: 20140927101712.jpg
@@ -233,7 +342,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
.captures(filename)
.and_then(|c| build_wall_from_ymd_capture(&c))
{
return Some(FilenameDate::Wall(naive));
return Some(FilenameMatch {
pattern: "compact",
date: FilenameDate::Wall(naive),
});
}
// 4. Timestamp format: 1401638400.jpeg, att_1422489664680106.jpeg, att_142248967186928.jpeg
@@ -281,7 +393,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
.and_then(DateTime::<Utc>::from_timestamp_millis)
.and_then(plausible_filename_date)
{
return Some(FilenameDate::Utc(date_time));
return Some(FilenameMatch {
pattern: "unix_timestamp",
date: FilenameDate::Utc(date_time),
});
}
// For 14-16 digits, treat first 10 digits as seconds to avoid far future dates
@@ -293,7 +408,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
.and_then(|ts| DateTime::<Utc>::from_timestamp(ts, 0))
.and_then(plausible_filename_date)
{
return Some(FilenameDate::Utc(date_time));
return Some(FilenameMatch {
pattern: "unix_timestamp",
date: FilenameDate::Utc(date_time),
});
}
// Exactly 10 digits - seconds since epoch
@@ -311,7 +429,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
if is_snapchat && date_time.timestamp() < SNAPCHAT_LAUNCH_TS {
return None;
}
return Some(FilenameDate::Utc(date_time));
return Some(FilenameMatch {
pattern: "unix_timestamp",
date: FilenameDate::Utc(date_time),
});
}
// 11-12 digits: try as milliseconds (might be partial millisecond timestamp)
@@ -322,13 +443,23 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
.and_then(DateTime::<Utc>::from_timestamp_millis)
.and_then(plausible_filename_date)
{
return Some(FilenameDate::Utc(date_time));
return Some(FilenameMatch {
pattern: "unix_timestamp",
date: FilenameDate::Utc(date_time),
});
}
}
None
}
/// Date-only projection of [`match_filename_date`]: the class match
/// without the pattern name. Resolver and consumer call sites use this;
/// the audit utility uses the full match.
pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
match_filename_date(filename).map(|m| m.date)
}
/// Sanity gate for filename-derived timestamps. Real photo capture dates
/// live in a narrow window; values outside it are almost always sequential
/// scan IDs (`000227580005.jpg` → 1970) or arbitrary numeric suffixes
@@ -350,17 +481,6 @@ fn plausible_filename_date(dt: DateTime<Utc>) -> Option<DateTime<Utc>> {
}
}
/// Convert a `date_taken` Unix-seconds value to a `NaiveDate` in the
/// client's local time. Falls back to server-local when the client didn't
/// send a tz hint.
fn date_in_client_tz(timestamp: i64, client_timezone: Option<FixedOffset>) -> Option<NaiveDate> {
let dt = DateTime::from_timestamp(timestamp, 0)?;
Some(match client_timezone {
Some(tz) => dt.with_timezone(&tz).date_naive(),
None => dt.with_timezone(&Local).date_naive(),
})
}
/// Default lookback for `/memories`. The original 15-year cap pre-dated
/// most of the imported libraries; bumped to 20 so users with deeper
/// archives see those photos surface on the matching anniversary too.
@@ -382,23 +502,29 @@ pub async fn list_memories(
let span_mode = q.span.unwrap_or(MemoriesSpan::Day);
// The SQL filter expects a signed offset in minutes from UTC; default
// 0 (UTC) when the client didn't send a hint. We also keep a chrono
// `FixedOffset` for sorting/secondary-key date math in Rust below —
// anchoring both sides on the same value keeps "what SQL matched" and
// "what we sort by" consistent.
let tz_offset_minutes = q.timezone_offset_minutes.unwrap_or(0);
let client_timezone = q
.timezone_offset_minutes
.and_then(|offset_mins| FixedOffset::east_opt(offset_mins * 60));
// Grouping happens in the owner's display zone (prefs → app default),
// not the client's device offset (ADR-0001): a photo taken in Japan at
// 21:00 JST belongs to the owner's day, and a container's UTC-rendered
// creation time is a true instant. The legacy `timezone_offset_minutes`
// param is still accepted on the wire for old clients but no longer
// drives grouping.
let display_zone_name = {
let prefs = app_state
.user_ai_prefs_dao
.lock()
.ok()
.and_then(|mut dao| dao.get_prefs(&span_context).ok().flatten());
crate::camera_tz::display_zone_for(prefs.as_ref().and_then(|p| p.display_tz.as_deref()))
};
let display_zone =
chrono_tz::Tz::from_str(&display_zone_name).unwrap_or(chrono_tz::Tz::America__New_York);
let items = match gather_memory_items(
&app_state,
&exif_dao,
&span_context,
span_mode,
tz_offset_minutes,
client_timezone,
display_zone,
q.library.as_deref(),
) {
Ok(items) => items,
@@ -414,7 +540,7 @@ pub async fn list_memories(
KeyValue::new("span", format!("{:?}", span_mode)),
KeyValue::new("years_back", DEFAULT_YEARS_BACK.to_string()),
KeyValue::new("result_count", items.len().to_string()),
KeyValue::new("tz_offset_minutes", tz_offset_minutes.to_string()),
KeyValue::new("display_tz", display_zone_name),
KeyValue::new("excluded_dirs", format!("{:?}", app_state.excluded_dirs)),
],
);
@@ -426,29 +552,41 @@ pub async fn list_memories(
/// Resolve an "on this day/week/month across past years" window into an
/// ordered list of [`MemoryItem`]s. Shared by the `/memories` handler and the
/// memory-reel selector so both honour the same library resolution, per-library
/// exclusions, timezone handling, and sort order. Returns `Err(message)` only
/// when the `library` param is invalid (callers map that to 400); per-library
/// exclusions, and sort order. Returns `Err(message)` only when the
/// `library` param is invalid (callers map that to 400); per-library
/// query/lock failures are logged and skipped, matching the handler's
/// best-effort behaviour.
///
/// Grouping is done in the owner's display zone (ADR-0001), on the row's
/// *true* instant — so a container's UTC-rendered creation time and an
/// unconverted EXIF wall clock both land in the same day a photo taken
/// locally did. The SQL pass is an interval pre-filter; exact span-day
/// matching happens here in Rust via [`date_view`].
pub fn gather_memory_items(
app_state: &AppState,
exif_dao: &Mutex<Box<dyn ExifDao>>,
span_context: &opentelemetry::Context,
span_mode: MemoriesSpan,
tz_offset_minutes: i32,
client_timezone: Option<FixedOffset>,
display_zone: chrono_tz::Tz,
library_param: Option<&str>,
) -> Result<Vec<MemoryItem>, String> {
let span_token = match span_mode {
MemoriesSpan::Day => "day",
MemoriesSpan::Week => "week",
MemoriesSpan::Month => "month",
};
let years_back: i32 = DEFAULT_YEARS_BACK;
let years_back: i64 = DEFAULT_YEARS_BACK as i64;
let now = Utc::now();
let now_ts = now.timestamp();
let today_d = display_zone
.from_utc_datetime(&now.naive_utc())
.date_naive();
// The SQL pass is a coarse pre-filter over the FULL years-back window
// (not the current span window) because this feature matches the same
// month-day / week / month across every past year; [`day_in_span`] does
// the exact matching in Rust (ADR-0001).
let (conv_lo, conv_hi, unconv_lo, unconv_hi) = memories_span_windows(now_ts, years_back);
let server_zone = crate::camera_tz::server_zone();
debug!(
"gather_memory_items: span={:?} tz_offset_min={} years_back={}",
span_mode, tz_offset_minutes, years_back
"gather_memory_items: span={:?} display_zone={:?} years_back={}",
span_mode, display_zone, years_back
);
let library = crate::libraries::resolve_library_param_state(app_state, library_param)?;
@@ -470,9 +608,10 @@ pub fn gather_memory_items(
Ok(mut dao) => match dao.get_memories_in_window(
span_context,
lib.id,
span_token,
years_back,
tz_offset_minutes,
conv_lo,
conv_hi,
unconv_lo,
unconv_hi,
) {
Ok(rows) => rows,
Err(e) => {
@@ -489,28 +628,37 @@ pub fn gather_memory_items(
}
};
for (rel_path, date_taken_ts, last_modified_ts) in rows {
for row in rows {
// Apply per-library exclusions in Rust — they're a small
// set and pushing them into the SQL WHERE adds bind-param
// gymnastics with no measurable win at this scale.
let full_path = base.join(&rel_path);
let full_path = base.join(&row.file_path);
if path_excluder.is_excluded(&full_path) {
trace!("Memory excluded by PathExcluder: {:?}", full_path);
continue;
}
let Some(file_date) = date_in_client_tz(date_taken_ts, client_timezone) else {
let Some(display_day) =
crate::date_view::row_display_day(&row, display_zone, server_zone)
else {
continue;
};
if !day_in_span(display_day, today_d, span_mode) {
continue;
}
let created = crate::date_view::row_true_instant(&row, display_zone, server_zone);
memories_with_dates.push((
MemoryItem {
path: rel_path,
created: Some(date_taken_ts),
modified: Some(last_modified_ts),
path: row.file_path,
created,
modified: Some(row.last_modified),
library_id: lib.id,
date_utc: row.date_utc,
camera_offset_minutes: row.camera_offset_minutes,
date_taken_tz_source: row.date_taken_tz_source,
},
file_date,
display_day,
));
}
}
@@ -782,6 +930,32 @@ mod tests {
assert_eq!(date_time.timestamp(), 1383929602);
}
// Pattern-name seam (ticket #117): the audit utility prints which
// pattern fired, so every class pattern must report a stable name.
#[test]
fn test_match_filename_date_reports_pattern_names() {
let cases: &[(&str, &str)] = &[
("Screenshot_2014-06-01-20-44-50.png", "screenshot"),
("Screenshot_20140601_204450.png", "screenshot_compact"),
("2015-01-09_02-15-15.jpg", "dash"),
("2015-01-09-021515.jpg", "dash_compact"),
("20140927101712.jpg", "compact"),
("att_1422489664680106.jpeg", "unix_timestamp"),
("1401638400.jpeg", "unix_timestamp"),
];
for (name, pattern) in cases {
let match_ =
match_filename_date(name).unwrap_or_else(|| panic!("no match for {}", name));
assert_eq!(match_.pattern, *pattern, "pattern name for {}", name);
}
}
#[test]
fn test_match_filename_date_out_of_class_is_none() {
assert_eq!(match_filename_date("IMG_12345.jpg"), None);
assert_eq!(match_filename_date("000227580005.jpg"), None);
}
// The obsolete `test_memory_date_priority_*` tests covered the old
// request-time waterfall in `get_memory_date_with_priority`. Their
// replacement lives in `crate::date_resolver::tests` (resolver
@@ -964,6 +1138,9 @@ mod tests {
created: Some(jan_15_2024_9am),
modified: Some(jan_15_2024_9am),
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
),
@@ -973,6 +1150,9 @@ mod tests {
created: Some(jan_15_2020_10am),
modified: Some(jan_15_2020_10am),
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
NaiveDate::from_ymd_opt(2020, 1, 15).unwrap(),
),
@@ -982,6 +1162,9 @@ mod tests {
created: Some(jan_16_2021_8am),
modified: Some(jan_16_2021_8am),
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
NaiveDate::from_ymd_opt(2021, 1, 16).unwrap(),
),
@@ -1013,4 +1196,325 @@ mod tests {
assert_eq!(memories_with_dates[1].0.created.unwrap(), jan_15_2024_9am);
assert_eq!(memories_with_dates[2].0.created.unwrap(), jan_16_2021_8am);
}
// ---- Day-matching (ticket #119) ----
#[test]
fn span_windows_cover_the_full_years_back_range() {
// Regression guard for the "current span window only" bug: the
// day/week/month spans match across PAST YEARS, so the SQL
// pre-filter must span the whole years-back horizon. If this
// narrowed to the current day/week/month, /memories would return
// only photos taken right now.
let now = 1_700_000_000i64;
let (clo, chi, ulo, uhi) = memories_span_windows(now, 20);
let lo_bound = now - 20 * 365 * 86_400;
assert_eq!(clo, lo_bound, "converted window must start at the horizon");
assert_eq!(chi, now, "converted window must end at now");
assert_eq!(
ulo,
lo_bound - 15 * 3600,
"unconverted band must pad the horizon"
);
assert_eq!(uhi, now + 15 * 3600, "unconverted band must pad now");
// The band subsumes the converted window so the DAO's single
// outer bound on legacy date_taken never drops a converted row.
assert!(ulo <= clo && chi <= uhi);
}
#[test]
fn day_in_span_day_and_month() {
let today = NaiveDate::from_ymd_opt(2024, 7, 4).unwrap();
assert!(day_in_span(
NaiveDate::from_ymd_opt(2024, 7, 4).unwrap(),
today,
MemoriesSpan::Day
));
assert!(!day_in_span(
NaiveDate::from_ymd_opt(2024, 7, 5).unwrap(),
today,
MemoriesSpan::Day
));
assert!(day_in_span(
NaiveDate::from_ymd_opt(2020, 7, 4).unwrap(),
today,
MemoriesSpan::Day
));
assert!(day_in_span(
NaiveDate::from_ymd_opt(2024, 7, 30).unwrap(),
today,
MemoriesSpan::Month
));
assert!(!day_in_span(
NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
today,
MemoriesSpan::Month
));
}
#[test]
fn day_in_span_week_is_monday_based_with_week_zero() {
let monday_2024 = NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(); // Mon
// Same week: Sunday Jul 7 is still week of Mon Jul 1.
assert!(day_in_span(
NaiveDate::from_ymd_opt(2024, 7, 7).unwrap(),
monday_2024,
MemoriesSpan::Week
));
// Next Monday is the next week.
assert!(!day_in_span(
NaiveDate::from_ymd_opt(2024, 7, 8).unwrap(),
monday_2024,
MemoriesSpan::Week
));
// Previous Sunday is the previous week.
assert!(!day_in_span(
NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
monday_2024,
MemoriesSpan::Week
));
// 2022 week 00: Jan 1 (Sat) and Jan 2 (Sun) share week 00; Jan 3
// (Mon) starts week 1.
let jan1_2022 = NaiveDate::from_ymd_opt(2022, 1, 1).unwrap();
assert!(day_in_span(
NaiveDate::from_ymd_opt(2022, 1, 2).unwrap(),
jan1_2022,
MemoriesSpan::Week
));
assert!(!day_in_span(
NaiveDate::from_ymd_opt(2022, 1, 3).unwrap(),
jan1_2022,
MemoriesSpan::Week
));
}
#[test]
fn sqlite_w_week_matches_known_values() {
// 2024: Jan 1 is a Monday → week 1 starts Jan 1.
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
1
);
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2024, 12, 30).unwrap()),
53
);
// 2022: Jan 1 is a Saturday → two-day week 00.
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2022, 1, 1).unwrap()),
0
);
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2022, 1, 2).unwrap()),
0
);
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2022, 1, 3).unwrap()),
1
);
// 2023: Jan 1 is a Sunday → one-day week 00.
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2023, 1, 1).unwrap()),
0
);
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2023, 1, 2).unwrap()),
1
);
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2023, 12, 31).unwrap()),
52
);
}
/// AC-2 / AC-3 / AC-7: seam test with mixed fixtures — a converted
/// row and two unconverted rows (EXIF wall convention + container UTC
/// convention) all group into the owner's display-zone month, and each
/// item carries its true instant under the per-source convention.
mod api {
use super::*;
use crate::database::SqliteExifDao;
use crate::database::models::InsertImageExif;
use crate::database::test::in_memory_db_connection;
fn seed_row(dao: &mut SqliteExifDao, rel: &str, date_taken: i64, source: &str) {
dao.store_exif(
&opentelemetry::Context::new(),
InsertImageExif {
library_id: 1,
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: Some(date_taken),
created_time: 0,
last_modified: 0,
content_hash: None,
size_bytes: None,
phash_64: None,
dhash_64: None,
date_taken_source: Some(source.to_string()),
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
)
.expect("insert row");
}
#[actix_rt::test]
async fn memories_mixed_rows_group_into_owner_month() {
let _ = env_logger::builder().is_test(true).try_init();
let state = AppState::test_state();
let zone = chrono_tz::Tz::America__New_York;
// Month span over the 5th of the CURRENT month in past years,
// at 12:00 naive: under all three conventions (camera wall in
// the server zone, container UTC, converted instant) the
// grouping day can't roll across midnight, so the fixture is
// stable across DST transitions and leap years.
let now = Utc::now();
let (month, year) = (now.month(), now.year());
let at_12 = |y: i32| {
NaiveDate::from_ymd_opt(y, month, 5)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap()
};
let mut dao = SqliteExifDao::from_connection(in_memory_db_connection());
// 1. Converted row: true instant 12:00 UTC (no camera offset).
let conv_instant = at_12(year - 3).and_utc().timestamp();
seed_row(&mut dao, "conv.jpg", conv_instant, "exif");
dao.backfill_date_taken(
&opentelemetry::Context::new(),
1,
"conv.jpg",
conv_instant,
"exif",
Some(conv_instant),
None,
Some("utc_direct"),
)
.expect("convert row");
// 2. Unconverted EXIF row: legacy is the camera wall read as
// UTC — the true moment is 12:00 in the server zone.
let exif_wall = at_12(year - 1);
let exif_legacy = exif_wall.and_utc().timestamp();
seed_row(&mut dao, "unconv-exif.jpg", exif_legacy, "exif");
// 3. Unconverted container row: legacy is the raw UTC creation
// time string rendered in the server zone (the old server
// read the epoch as a naive string and stamped it through
// its local zone). Pre-#115 this row double-shifted in
// /memories; it must now land in the same owner month as
// the other two.
let vid_legacy = at_12(year - 2).and_utc().timestamp();
seed_row(&mut dao, "unconv-vid.mp4", vid_legacy, "exiftool");
let exif_mutex: Mutex<Box<dyn ExifDao>> = Mutex::new(Box::new(dao));
let items = gather_memory_items(
&state,
&exif_mutex,
&opentelemetry::Context::new(),
MemoriesSpan::Month,
zone,
None,
)
.expect("gather");
let by_path = |p: &str| items.iter().find(|it| it.path == p);
// All three rows group into the same owner month (AC-2/AC-3).
let conv = by_path("conv.jpg").expect("converted row must be returned");
let exif_row =
by_path("unconv-exif.jpg").expect("unconverted EXIF row must be returned");
let vid =
by_path("unconv-vid.mp4").expect("unconverted container row must be returned");
// AC-1: the triple rides along — populated for the converted
// row, None for the unconverted ones.
assert_eq!(conv.date_utc, Some(conv_instant));
assert_eq!(conv.camera_offset_minutes, None);
assert_eq!(conv.date_taken_tz_source.as_deref(), Some("utc_direct"));
assert_eq!(exif_row.date_utc, None);
assert_eq!(exif_row.camera_offset_minutes, None);
assert_eq!(exif_row.date_taken_tz_source, None);
assert_eq!(vid.date_utc, None);
// AC-2: `created` is the true instant under each row's
// per-source convention. The server zone (the zone the old
// server rendered legacy values in) is a deploy property, so
// the expected values are derived from the convention, not
// hardcoded.
let server_zone = crate::camera_tz::server_zone();
let legacy_as_wall = |ts: i64| {
chrono::DateTime::from_timestamp(ts, 0)
.expect("valid epoch")
.naive_utc()
};
// exif: camera wall reinterpreted in the server zone.
let exif_expected = match server_zone {
Some(z) => z
.from_local_datetime(&legacy_as_wall(exif_legacy))
.single()
.expect("camera wall in server zone")
.timestamp(),
None => exif_legacy,
};
// exiftool video: raw UTC naive rendered in the server zone,
// read back as UTC.
let vid_expected = match server_zone {
Some(z) => z
.timestamp_opt(vid_legacy, 0)
.single()
.expect("server-zone render")
.naive_local()
.and_utc()
.timestamp(),
None => vid_legacy,
};
assert_eq!(conv.created, Some(conv_instant));
assert_eq!(exif_row.created, Some(exif_expected));
assert_eq!(vid.created, Some(vid_expected));
}
/// AC-1 (response shape): the serialized `/memories` item carries
/// the new-contract triple as explicit fields — null while the row
/// is unconverted.
#[test]
fn memories_response_shape_carries_triple() {
let item = MemoryItem {
path: "a.jpg".into(),
created: Some(1_700_000_000),
modified: Some(0),
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
};
let v: serde_json::Value =
serde_json::to_value(&MemoriesResponse { items: vec![item] }).unwrap();
let it = &v["items"][0];
assert!(it.get("date_utc").is_some() && it["date_utc"].is_null());
assert!(
it.get("camera_offset_minutes").is_some() && it["camera_offset_minutes"].is_null()
);
assert!(it.get("date_taken_tz_source").is_some());
}
}
}
+126 -1
View File
@@ -2,7 +2,9 @@ use actix_web::HttpRequest;
use actix_web::http::header::HeaderMap;
use opentelemetry::global::{BoxedSpan, BoxedTracer};
use opentelemetry::propagation::TextMapPropagator;
use opentelemetry::trace::{Span, Status, Tracer};
use opentelemetry::trace::{
Span, SpanBuilder, SpanContext, SpanKind, Status, TraceContextExt, Tracer,
};
use opentelemetry::{Context, KeyValue, global};
use opentelemetry_appender_log::OpenTelemetryLogBridge;
use opentelemetry_otlp::WithExportConfig;
@@ -77,11 +79,78 @@ impl<'a> opentelemetry::propagation::Extractor for HeaderExtractor<'a> {
self.0.keys().map(|k| k.as_str()).collect()
}
}
/// Start a client span for a single LLM call. Every provider funnels its
/// LlmClient methods through this so the span shape (name, `SpanKind::Client`,
/// provider / operation / model attributes) is identical across
/// openrouter / llamacpp / ollama. `operation` is one of `chat`,
/// `chat_stream`, `embeddings`, `describe_image`, `generate`.
pub fn start_llm_request_span(
cx: &Context,
provider: &str,
operation: &str,
model: &str,
) -> BoxedSpan {
let builder = SpanBuilder::from_name("llm.request").with_kind(SpanKind::Client);
let mut span = global_tracer().build_with_context(builder, cx);
span.set_attribute(KeyValue::new("llm.provider", provider.to_string()));
span.set_attribute(KeyValue::new("llm.operation", operation.to_string()));
span.set_attribute(KeyValue::new("llm.model", model.to_string()));
span
}
pub fn extract_context_from_request(req: &HttpRequest) -> Context {
let propagator = TraceContextPropagator::new();
propagator.extract(&HeaderExtractor(req.headers()))
}
/// Build the W3C trace-context headers to attach to an outgoing HTTP
/// request so a downstream service (llama-swap, Ollama) can parent its own
/// spans onto this trace. Returns an empty vec when the context carries no
/// valid span — e.g. debug builds, where the global tracer is a no-op and
/// injecting an all-zero traceparent would produce garbage parent spans.
pub fn traceparent_headers(context: &Context) -> Vec<(String, String)> {
if !context.span().span_context().is_valid() {
return Vec::new();
}
let mut headers: std::collections::HashMap<String, String> = std::collections::HashMap::new();
TraceContextPropagator::new().inject_context(context, &mut headers);
// The propagator writes an empty tracestate header when there is no
// state — drop empty-valued headers rather than sending noise.
headers.into_iter().filter(|(_, v)| !v.is_empty()).collect()
}
/// Attach the W3C traceparent header to an outgoing request so a downstream
/// service parents its own spans onto this trace. No-op when the span context
/// is not valid — e.g. debug builds, where the global tracer is a no-op and an
/// all-zero traceparent would produce garbage parent spans.
pub fn with_traceparent(
builder: reqwest::RequestBuilder,
span_context: &SpanContext,
) -> reqwest::RequestBuilder {
let mut b = builder;
for (k, v) in
traceparent_headers(&Context::new().with_remote_span_context(span_context.clone()))
{
b = b.header(k, v);
}
b
}
/// Record the HTTP status code on the span (success or failure path).
pub fn set_http_status(span: Option<&mut BoxedSpan>, status: u16) {
if let Some(s) = span {
s.set_attribute(KeyValue::new("http.status_code", status as i64));
}
}
/// Record an HTTP failure on the span: status code plus an error status.
pub fn record_http_failure(span: Option<&mut BoxedSpan>, status: u16, message: &str) {
if let Some(s) = span {
s.set_attribute(KeyValue::new("http.status_code", status as i64));
s.set_status(Status::error(message.to_string()));
}
}
pub fn trace_db_call<F, O>(
context: &Context,
query_type: &str,
@@ -110,3 +179,59 @@ where
result
}
#[cfg(test)]
mod traceparent_tests {
use super::*;
use opentelemetry::trace::{
SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId, TraceState,
};
/// A context carrying a span context with known ids — no global
/// provider needed, so tests can't pollute process state.
fn context_with_flags(flags: TraceFlags) -> Context {
let trace_id = TraceId::from_bytes([
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
0x0f, 0x10,
]);
let span_id = SpanId::from_bytes([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
let span_context = SpanContext::new(trace_id, span_id, flags, false, TraceState::default());
Context::new().with_remote_span_context(span_context)
}
#[test]
fn traceparent_headers_sampled_context() {
let cx = context_with_flags(TraceFlags::SAMPLED);
let headers = traceparent_headers(&cx);
assert_eq!(
headers,
vec![(
"traceparent".to_string(),
"00-0102030405060708090a0b0c0d0e0f10-0102030405060708-01".to_string(),
)]
);
}
#[test]
fn traceparent_headers_not_sampled_context() {
// A valid-but-unsampled context still propagates; the flags byte
// carries the state.
let cx = context_with_flags(TraceFlags::NOT_SAMPLED);
let headers = traceparent_headers(&cx);
assert_eq!(
headers,
vec![(
"traceparent".to_string(),
"00-0102030405060708090a0b0c0d0e0f10-0102030405060708-00".to_string(),
)]
);
}
#[test]
fn traceparent_headers_invalid_context_is_empty() {
// No span (debug-build no-op tracer) → no header, never an
// all-zero traceparent.
let cx = Context::new();
assert!(traceparent_headers(&cx).is_empty());
}
}
+1066
View File
File diff suppressed because it is too large. Load diff
+196 -30
View File
@@ -18,6 +18,7 @@ pub mod selector;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::{LazyLock, Mutex, Mutex as StdMutex};
use std::time::{Duration, Instant};
@@ -71,13 +72,38 @@ pub fn normalize_library_key(libs: &[Library], param: Option<&str>) -> String {
}
}
/// Reel span / segment-cap resolution: explicit request param → stored
/// owner pref → hard default. A stored span that no longer parses (e.g.
/// hand-edited) is skipped rather than erroring — the hard default still
/// applies. Non-positive stored segment caps are ignored the same way.
fn resolve_reel_params(
req_span: Option<MemoriesSpan>,
req_max_segments: Option<usize>,
prefs: Option<&crate::database::models::UserAiPrefs>,
) -> (MemoriesSpan, usize) {
let pref_span = prefs
.and_then(|p| p.reel_span.as_deref())
.and_then(|s| MemoriesSpan::from_str(s).ok());
let pref_max_segments = prefs
.and_then(|p| p.reel_max_segments)
.filter(|v| *v > 0)
.map(|v| v as usize);
let span = req_span.or(pref_span).unwrap_or(MemoriesSpan::Day);
let max_segments = req_max_segments
.or(pref_max_segments)
.unwrap_or(selector::DEFAULT_MAX_SEGMENTS);
(span, max_segments)
}
/// Best-effort: mirror the latest client reel params into `user_ai_prefs`
/// so the nightly pre-gen scheduler can pick them up. Never fails the
/// caller regardless of DB errors.
fn capture_prefs(
app_state: &AppState,
req: &web::Json<CreateReelRequest>,
req: &CreateReelRequest,
library_param: Option<&str>,
effective_span: MemoriesSpan,
effective_max_segments: usize,
) -> Result<(), anyhow::Error> {
use crate::database::models::UpsertUserAiPrefs;
let now = std::time::SystemTime::now()
@@ -93,6 +119,21 @@ fn capture_prefs(
};
let mut dao = app_state.user_ai_prefs_dao.lock().expect("lock");
let ctx = opentelemetry::Context::new();
// The row passively mirrors the latest request's EFFECTIVE reel params
// (after the request → pref → default resolution done by the caller), so
// the stored reel_span / reel_max_segments track what was last used —
// idempotent when the pref itself supplied the effective value.
//
// The owner-default fields (model/backend/persona) are owned by PUT /prefs,
// not by reel requests. The upsert overwrites every column, so we must
// carry the existing values through unchanged — writing None here would
// silently wipe the owner's stored defaults on every reel creation. If the
// read fails we bail without writing: this is best-effort, and clobbering
// owner defaults on a transient error is worse than skipping a mirror.
let existing = match dao.get_prefs(&ctx) {
Ok(row) => row,
Err(e) => return Err(anyhow::anyhow!("failed to read prefs before mirror: {e}")),
};
dao.upsert_prefs(
&ctx,
&UpsertUserAiPrefs {
@@ -104,6 +145,11 @@ fn capture_prefs(
library: Some(library),
updated_at: now as i64,
display_tz: req.display_tz.clone().filter(|s| !s.is_empty()),
default_model: existing.as_ref().and_then(|e| e.default_model.clone()),
default_backend: existing.as_ref().and_then(|e| e.default_backend.clone()),
default_persona_id: existing.as_ref().and_then(|e| e.default_persona_id.clone()),
reel_span: Some(effective_span.to_string()),
reel_max_segments: Some(effective_max_segments as i32),
},
)
.map_err(|e| anyhow::anyhow!("failed to upsert user_ai_prefs: {e}"))
@@ -415,11 +461,26 @@ pub async fn create_reel_handler(
}));
}
let span = req.span.unwrap_or(MemoriesSpan::Day);
let max_segments = req.max_segments.unwrap_or(selector::DEFAULT_MAX_SEGMENTS);
// Owner defaults: explicit request param → stored pref → hard default.
// The stored row is read once and reused for the span, the segment cap,
// and the display zone. The legacy device-offset param is still mirrored
// into the frozen prefs column by `capture_prefs` for rollback but no
// longer drives selection.
let stored_prefs = app_state
.user_ai_prefs_dao
.lock()
.ok()
.and_then(|mut dao| dao.get_prefs(&opentelemetry::Context::new()).ok().flatten());
let (span, max_segments) =
resolve_reel_params(req.span, req.max_segments, stored_prefs.as_ref());
let display_zone_name = {
let req_tz = req.display_tz.clone().filter(|s| !s.trim().is_empty());
let stored = stored_prefs.as_ref().and_then(|p| p.display_tz.clone());
crate::camera_tz::display_zone_for(req_tz.as_deref().or(stored.as_deref()))
};
let selector = ReelSelector::Memories {
span,
tz_offset_minutes: req.timezone_offset_minutes.unwrap_or(0),
display_tz: display_zone_name,
library: req.library.clone(),
max_segments,
};
@@ -475,7 +536,7 @@ pub async fn create_reel_handler(
},
);
// Capture params for passive prefs mirror (best-effort, never fails).
let _ = capture_prefs(&app_state, &req, req.library.as_deref());
let _ = capture_prefs(&app_state, &req, req.library.as_deref(), span, max_segments);
return HttpResponse::Accepted().json(ReelJobCreatedResponse {
job_id: job_id.to_string(),
status: ReelJobStatus::Done,
@@ -535,7 +596,7 @@ pub async fn create_reel_handler(
with_job(job_id, |job| job.abort = Some(handle.abort_handle()));
// Capture params for passive prefs mirror (best-effort, never fails).
let _ = capture_prefs(&app_state, &req, req.library.as_deref());
let _ = capture_prefs(&app_state, &req, req.library.as_deref(), span, max_segments);
HttpResponse::Accepted().json(ReelJobCreatedResponse {
job_id: job_id.to_string(),
@@ -965,23 +1026,26 @@ pub(crate) fn secs_until_next_run_hour(now: chrono::DateTime<chrono::Local>, run
}
/// Load pre-gen parameters: tries the user_ai_prefs DB row first, falls back
/// to env vars, then to server-local defaults.
fn load_pregen_params(app_state: &AppState) -> (i32, Option<String>, String) {
/// to env vars, then to the app default display zone. Returns the display
/// zone as an IANA name (ADR-0001) — `REEL_PREGEN_DISPLAY_TZ` (e.g.
/// "America/New_York") overrides the stored pref when set. IANA zones are
/// DST-proof, unlike the old fixed-offset env vars.
fn load_pregen_params(app_state: &AppState) -> (String, Option<String>, String) {
// Try DB row first
if let Ok(mut dao) = app_state.user_ai_prefs_dao.lock() {
let ctx = opentelemetry::Context::new();
if let Ok(Some(prefs)) = dao.get_prefs(&ctx) {
let tz = prefs.tz_offset_minutes.unwrap_or_else(fixed_tz_offset);
let tz = crate::camera_tz::display_zone_for(prefs.display_tz.as_deref());
let voice = prefs.voice;
let library = prefs.library.unwrap_or_else(|| "all".to_string());
return (tz, voice, library);
}
}
// Fall back to env (explicit offset overrides auto-detect)
let tz = std::env::var("REEL_PREGEN_TZ_OFFSET_MINUTES")
// Fall back to env (explicit zone overrides auto-detect)
let tz = std::env::var("REEL_PREGEN_DISPLAY_TZ")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or_else(fixed_tz_offset);
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| crate::camera_tz::display_zone_for(None));
let voice = std::env::var("REEL_PREGEN_VOICE").ok();
let library = std::env::var("REEL_PREGEN_LIBRARY")
.ok()
@@ -989,17 +1053,6 @@ fn load_pregen_params(app_state: &AppState) -> (i32, Option<String>, String) {
(tz, voice, library)
}
/// Fixed timezone offset: reads `REEL_PREGEN_TZ_FIXED_MINUTES` (e.g. "-480"
/// for US Eastern) when set, falling back to the system local offset. Using
/// a fixed offset avoids DST shifts changing the pre-gen schedule halfway
/// through the year.
fn fixed_tz_offset() -> i32 {
std::env::var("REEL_PREGEN_TZ_FIXED_MINUTES")
.ok()
.and_then(|v| v.trim().parse().ok())
.unwrap_or_else(|| chrono::Local::now().offset().local_minus_utc())
}
/// Spawn the nightly pre-generation scheduler. Runs behind `REEL_PREGEN_ENABLED`.
pub(crate) async fn spawn_pregen_scheduler(app_state: web::Data<AppState>) {
if std::env::var("REEL_PREGEN_ENABLED").ok() != Some("1".to_string()) {
@@ -1044,7 +1097,7 @@ async fn run_pregen_batch(app_state: &AppState) -> anyhow::Result<()> {
let (tz, voice, library) = load_pregen_params(app_state);
for span in spans {
if let Err(e) = pregen_one(app_state, span, tz, voice.clone(), &library).await {
if let Err(e) = pregen_one(app_state, span, &tz, voice.clone(), &library).await {
log::error!("Pre-gen failed for span={}: {}", span, e);
}
}
@@ -1056,7 +1109,7 @@ async fn run_pregen_batch(app_state: &AppState) -> anyhow::Result<()> {
async fn pregen_one(
app_state: &AppState,
span: &str,
tz: i32,
display_tz: &str,
voice: Option<String>,
library: &str,
) -> anyhow::Result<()> {
@@ -1069,7 +1122,7 @@ async fn pregen_one(
let selector = ReelSelector::Memories {
span: memories_span,
tz_offset_minutes: tz,
display_tz: display_tz.to_string(),
library: if library == "all" {
None
} else {
@@ -1179,7 +1232,8 @@ async fn pregen_one(
title,
media_count,
render_version: RENDER_VERSION as i32,
tz_offset_minutes: tz,
tz_offset_minutes: crate::camera_tz::display_zone_offset_minutes(display_tz, now)
.unwrap_or(0) as i32,
voice: voice.clone(),
generated_at: now,
},
@@ -1326,9 +1380,121 @@ fn sweep_reel_cache(app_state: &AppState) -> usize {
#[cfg(test)]
mod tests {
use super::*;
use crate::database::models::UserAiPrefs;
use crate::libraries::Library;
use chrono::TimeZone;
fn prefs_row(span: Option<&str>, max_segments: Option<i32>) -> UserAiPrefs {
UserAiPrefs {
id: 1,
voice: None,
tz_offset_minutes: None,
library: None,
updated_at: 0,
display_tz: None,
default_model: None,
default_backend: None,
default_persona_id: None,
reel_span: span.map(str::to_string),
reel_max_segments: max_segments,
}
}
#[test]
fn resolve_reel_params_request_wins_over_pref() {
let prefs = prefs_row(Some("month"), Some(10));
let (span, max) = resolve_reel_params(Some(MemoriesSpan::Day), Some(5), Some(&prefs));
assert_eq!(span, MemoriesSpan::Day);
assert_eq!(max, 5);
}
#[test]
fn resolve_reel_params_pref_fills_omitted_request() {
let prefs = prefs_row(Some("week"), Some(24));
let (span, max) = resolve_reel_params(None, None, Some(&prefs));
assert_eq!(span, MemoriesSpan::Week);
assert_eq!(max, 24);
}
#[test]
fn resolve_reel_params_no_pref_falls_to_hard_defaults() {
let (span, max) = resolve_reel_params(None, None, None);
assert_eq!(span, MemoriesSpan::Day);
assert_eq!(max, selector::DEFAULT_MAX_SEGMENTS);
}
#[test]
fn resolve_reel_params_invalid_or_nonpositive_pref_skipped() {
// Hand-edited / corrupt stored values fall through to hard defaults
// instead of erroring the request.
let bad_span = prefs_row(Some("decade"), Some(0));
let (span, max) = resolve_reel_params(None, None, Some(&bad_span));
assert_eq!(span, MemoriesSpan::Day);
assert_eq!(max, selector::DEFAULT_MAX_SEGMENTS);
}
/// Regression: reel creation is a PASSIVE mirror. The upsert overwrites
/// every column, so `capture_prefs` must carry the PUT /prefs-owned
/// default_model / default_backend / default_persona_id through unchanged
/// — a reel request has no business wiping the owner's AI defaults.
#[actix_rt::test]
async fn reel_capture_preserves_owner_defaults() {
use crate::database::models::UpsertUserAiPrefs;
use crate::database::{SqliteUserAiPrefsDao, UserAiPrefsDao};
use crate::state::AppState;
use std::sync::{Arc, Mutex};
let conn = Arc::new(Mutex::new(crate::database::test::in_memory_db_connection()));
let mut state = AppState::test_state();
state.user_ai_prefs_dao = Arc::new(Mutex::new(Box::new(
SqliteUserAiPrefsDao::from_connection(conn.clone()),
)));
{
let mut dao = SqliteUserAiPrefsDao::from_connection(conn.clone());
dao.upsert_prefs(
&opentelemetry::Context::new(),
&UpsertUserAiPrefs {
voice: None,
tz_offset_minutes: None,
library: None,
updated_at: 0,
display_tz: None,
default_model: Some("llama3.2:3b".into()),
default_backend: Some("hybrid".into()),
default_persona_id: Some("cabin".into()),
reel_span: None,
reel_max_segments: None,
},
)
.expect("seed owner defaults");
}
let req = CreateReelRequest {
span: Some(MemoriesSpan::Week),
timezone_offset_minutes: Some(-240),
display_tz: None,
library: None,
voice: Some("grandma".into()),
max_segments: Some(12),
};
capture_prefs(&state, &req, None, MemoriesSpan::Week, 12).expect("capture");
let row = state
.user_ai_prefs_dao
.lock()
.expect("lock")
.get_prefs(&opentelemetry::Context::new())
.expect("read")
.expect("row");
// The mirrored reel params landed...
assert_eq!(row.reel_span.as_deref(), Some("week"));
assert_eq!(row.reel_max_segments, Some(12));
// ...and the owner defaults survived untouched.
assert_eq!(row.default_model.as_deref(), Some("llama3.2:3b"));
assert_eq!(row.default_backend.as_deref(), Some("hybrid"));
assert_eq!(row.default_persona_id.as_deref(), Some("cabin"));
}
fn photo(p: &str, lib: i32) -> SegmentMedia {
SegmentMedia::Photo {
rel_path: p.to_string(),
@@ -1346,7 +1512,7 @@ mod tests {
fn day_selector() -> ReelSelector {
ReelSelector::Memories {
span: MemoriesSpan::Day,
tz_offset_minutes: 0,
display_tz: "America/New_York".to_string(),
library: None,
max_segments: 24,
}
@@ -1379,7 +1545,7 @@ mod tests {
// Span matters.
let week = ReelSelector::Memories {
span: MemoriesSpan::Week,
tz_offset_minutes: 0,
display_tz: "America/New_York".to_string(),
library: None,
max_segments: 24,
};
+2 -1
View File
@@ -281,8 +281,9 @@ pub async fn generate_script(
beats: &[PlannedBeat],
) -> Result<ReelScript> {
let (system, user) = build_script_messages(meta, beats);
let cx = opentelemetry::Context::current();
let raw = client
.generate(&user, Some(&system), None)
.generate(&cx, &user, Some(&system), None)
.await
.context("LLM script generation failed")?;
Ok(parse_script_response(&raw, beats.len()))
+34 -18
View File
@@ -9,9 +9,10 @@
//! runs in the background job.
use std::path::Path;
use std::str::FromStr;
use std::sync::Mutex;
use chrono::{DateTime, Datelike, FixedOffset};
use chrono::{Datelike, TimeZone};
use super::{PlannedBeat, ReelMeta, SegmentMedia};
use crate::database::{ExifDao, InsightDao};
@@ -64,7 +65,10 @@ pub fn budget_segments(requested_max: usize) -> usize {
pub enum ReelSelector {
Memories {
span: MemoriesSpan,
tz_offset_minutes: i32,
/// Owner's display zone as an IANA name (ADR-0001) — grouping is
/// zone-anchored, not device-offset-anchored, so DST shifts are
/// absorbed instead of silently re-bucketing the reel.
display_tz: String,
library: Option<String>,
max_segments: usize,
},
@@ -78,13 +82,13 @@ impl ReelSelector {
match self {
ReelSelector::Memories {
span,
tz_offset_minutes,
display_tz,
library,
max_segments,
} => format!(
"memories:span={:?}:tz={}:lib={}:max={}",
span,
tz_offset_minutes,
display_tz,
library.as_deref().unwrap_or("all"),
max_segments
),
@@ -281,24 +285,24 @@ pub fn resolve(
match selector {
ReelSelector::Memories {
span,
tz_offset_minutes,
display_tz,
library,
max_segments,
} => {
let client_tz = FixedOffset::east_opt(tz_offset_minutes * 60);
let zone =
chrono_tz::Tz::from_str(display_tz).unwrap_or(chrono_tz::Tz::America__New_York);
let items = memories::gather_memory_items(
app_state,
exif_dao,
span_context,
*span,
*tz_offset_minutes,
client_tz,
zone,
library.as_deref(),
)?;
// Split into photos and video clips; anything that's neither is
// dropped. Years span both, computed before the budget narrows it.
let years = distinct_years(&items, client_tz);
let years = distinct_years(&items, zone);
let meta = ReelMeta { span: *span, years };
let (photos, videos): (Vec<_>, Vec<_>) = items
@@ -321,15 +325,12 @@ pub fn resolve(
/// Distinct calendar years represented by the selected media, in the client's
/// timezone, ascending. Used to tell the scripter how far back the reel reaches.
fn distinct_years(items: &[memories::MemoryItem], tz: Option<FixedOffset>) -> Vec<i32> {
fn distinct_years(items: &[memories::MemoryItem], zone: chrono_tz::Tz) -> Vec<i32> {
let mut years: Vec<i32> = items
.iter()
.filter_map(|it| it.created)
.filter_map(|ts| DateTime::from_timestamp(ts, 0))
.map(|dt| match tz {
Some(off) => dt.with_timezone(&off).year(),
None => dt.year(),
})
.filter_map(|ts| zone.timestamp_opt(ts, 0).single())
.map(|dt| dt.year())
.collect();
years.sort_unstable();
years.dedup();
@@ -406,13 +407,13 @@ mod tests {
fn descriptor_is_stable_and_distinguishes_inputs() {
let a = ReelSelector::Memories {
span: MemoriesSpan::Day,
tz_offset_minutes: -480,
display_tz: "America/New_York".to_string(),
library: None,
max_segments: 24,
};
let b = ReelSelector::Memories {
span: MemoriesSpan::Week,
tz_offset_minutes: -480,
display_tz: "America/New_York".to_string(),
library: None,
max_segments: 24,
};
@@ -429,21 +430,33 @@ mod tests {
created: Some(1_560_000_000), // 2019
modified: None,
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
memories::MemoryItem {
path: "b".into(),
created: Some(1_560_086_400), // 2019
modified: None,
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
memories::MemoryItem {
path: "c".into(),
created: Some(1_623_000_000), // 2021
modified: None,
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
];
assert_eq!(distinct_years(&items, None), vec![2019, 2021]);
assert_eq!(
distinct_years(&items, chrono_tz::Tz::America__New_York),
vec![2019, 2021]
);
}
// Build an item at a given unix timestamp (seconds) with a chosen extension.
@@ -453,6 +466,9 @@ mod tests {
created: Some(ts),
modified: None,
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
}
}
fn item_at(ts: i64, name: &str) -> memories::MemoryItem {
+2
View File
@@ -323,6 +323,7 @@ impl Default for AppState {
face_dao.clone(),
knowledge_dao,
persona_dao.clone(),
user_ai_prefs_dao.clone(),
libraries_vec.clone(),
);
@@ -550,6 +551,7 @@ impl AppState {
face_dao.clone(),
knowledge_dao,
persona_dao.clone(),
Arc::new(Mutex::new(Box::new(SqliteUserAiPrefsDao::new()))),
vec![test_lib],
);
+1 -1
View File
@@ -211,7 +211,7 @@ pub async fn unified_search<TagD: TagDao>(
log::info!("unified_search: translating with model={}", backend.model());
let today = chrono::Utc::now().date_naive();
let sq = match translate_nl_query(backend.chat(), &nl, &tag_vocab, today).await {
let sq = match translate_nl_query(backend.chat(), &nl, &tag_vocab, today, &ctx).await {
Ok(sq) => sq,
Err(e) => {
log::warn!("unified_search: translate_nl_query failed: {e:?}");