Author SHA1 Message Date
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
43 changed files with 4845 additions and 548 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) POST /insights/chat/stream (SSE: text / tool_call / tool_result / truncated / done)
GET /insights/chat/history?path=... (rendered transcript with tool invocations) GET /insights/chat/history?path=... (rendered transcript with tool invocations)
POST /insights/chat/rewind (truncate transcript at a rendered index) 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:** **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 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. boundary invalidates the derived data for that day, which regenerates on its normal schedule.
_Avoid_: cache, nightly output _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,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("date", date.to_string()));
span.set_attribute(KeyValue::new("contact", contact.to_string())); span.set_attribute(KeyValue::new("contact", contact.to_string()));
span.set_attribute(KeyValue::new("message_count", messages.len() as i64)); 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); let (prompt, system_prompt) = build_daily_summary_prompt(contact, date, messages);
// Generate summary with LLM // 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!( log::debug!(
"Generated summary for {}: {}", "Generated summary for {}: {}",
@@ -382,7 +388,9 @@ async fn generate_and_store_daily_summary(
); );
// Embed the stripped summary (store original summary in DB) // 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( span.set_attribute(KeyValue::new(
"embedding_dimensions", "embedding_dimensions",
+175 -1
View File
@@ -384,6 +384,36 @@ pub struct ServerModels {
pub default_model: String, 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 - Generate insight for a specific photo (async)
#[post("/insights/generate")] #[post("/insights/generate")]
pub async fn generate_insight_handler( pub async fn generate_insight_handler(
@@ -396,6 +426,16 @@ pub async fn generate_insight_handler(
let tracer = global_tracer(); let tracer = global_tracer();
let mut span = tracer.start_with_context("http.insights.generate", &parent_context); 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 normalized_path = normalize_path(&request.file_path);
let library = app_state.primary_library(); let library = app_state.primary_library();
let gen_type = InsightGenerationType::Standard; let gen_type = InsightGenerationType::Standard;
@@ -714,6 +754,16 @@ pub async fn generate_agentic_insight_handler(
let tracer = global_tracer(); let tracer = global_tracer();
let mut span = tracer.start_with_context("http.insights.generate_agentic", &parent_context); 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 normalized_path = normalize_path(&request.file_path);
let library = app_state.primary_library(); let library = app_state.primary_library();
let gen_type = InsightGenerationType::Agentic; 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); let mut span = tracer.start_with_context("http.insights.chat", &parent_context);
span.set_attribute(KeyValue::new("file_path", request.file_path.clone())); 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 = let library =
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) { match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
Ok(Some(lib)) => lib, Ok(Some(lib)) => lib,
@@ -1607,10 +1667,22 @@ pub struct ChatSwitchBranchRequest {
/// Returns `text/event-stream` with one event per chat stream event. /// Returns `text/event-stream` with one event per chat stream event.
#[post("/insights/chat/stream")] #[post("/insights/chat/stream")]
pub async fn chat_stream_handler( pub async fn chat_stream_handler(
http_request: HttpRequest,
claims: Claims, claims: Claims,
request: web::Json<ChatTurnHttpRequest>, request: web::Json<ChatTurnHttpRequest>,
app_state: web::Data<AppState>, app_state: web::Data<AppState>,
) -> HttpResponse { ) -> 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 = let library =
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) { match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
Ok(Some(lib)) => lib, Ok(Some(lib)) => lib,
@@ -1646,7 +1718,7 @@ pub async fn chat_stream_handler(
}; };
let service = app_state.insight_chat.clone(); 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. // Map ChatStreamEvent → SSE frame bytes.
let sse_stream = futures::stream::StreamExt::map(events, |ev| { 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); 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())); 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 = let library =
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) { match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
Ok(Some(lib)) => lib, Ok(Some(lib)) => lib,
@@ -2277,3 +2359,95 @@ mod turn_replay_tests {
assert_eq!(info.total_events_pushed, 2); 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 anyhow::{Result, anyhow, bail};
use chrono::Utc; use chrono::{TimeZone, Utc};
use opentelemetry::KeyValue; use opentelemetry::KeyValue;
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
use std::collections::HashMap; use std::collections::HashMap;
@@ -377,7 +377,7 @@ impl InsightChatService {
let (response, prompt_tokens, eval_tokens) = backend let (response, prompt_tokens, eval_tokens) = backend
.chat() .chat()
.chat_with_tools(messages.clone(), tools.clone()) .chat_with_tools(&loop_cx, messages.clone(), tools.clone())
.await?; .await?;
last_prompt_eval_count = prompt_tokens; last_prompt_eval_count = prompt_tokens;
last_eval_count = eval_tokens; last_eval_count = eval_tokens;
@@ -434,7 +434,7 @@ impl InsightChatService {
let synthetic_idx = push_synthetic_final_prompt(&mut messages); let synthetic_idx = push_synthetic_final_prompt(&mut messages);
let (final_response, prompt_tokens, eval_tokens) = backend let (final_response, prompt_tokens, eval_tokens) = backend
.chat() .chat()
.chat_with_tools(messages.clone(), vec![]) .chat_with_tools(&loop_cx, messages.clone(), vec![])
.await?; .await?;
last_prompt_eval_count = prompt_tokens; last_prompt_eval_count = prompt_tokens;
last_eval_count = eval_tokens; last_eval_count = eval_tokens;
@@ -492,6 +492,7 @@ impl InsightChatService {
let title_raw = backend let title_raw = backend
.chat() .chat()
.generate( .generate(
&loop_cx,
&title_prompt, &title_prompt,
Some( Some(
"You are my long term memory assistant. Use only the information provided. Do not invent details.", "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( pub fn chat_turn_stream(
self: Arc<Self>, self: Arc<Self>,
req: ChatTurnRequest, req: ChatTurnRequest,
cx: opentelemetry::Context,
) -> BoxStream<'static, ChatStreamEvent> { ) -> BoxStream<'static, ChatStreamEvent> {
let svc = self; let svc = self;
let s = async_stream::stream! { 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) => { Ok(mut rx) => {
while let Some(ev) = rx.recv().await { while let Some(ev) = rx.recv().await {
yield ev; yield ev;
@@ -806,6 +808,7 @@ impl InsightChatService {
async fn chat_turn_stream_inner<F>( async fn chat_turn_stream_inner<F>(
self: Arc<Self>, self: Arc<Self>,
req: ChatTurnRequest, req: ChatTurnRequest,
cx: opentelemetry::Context,
_ev_mapper: F, _ev_mapper: F,
) -> Result<tokio::sync::mpsc::Receiver<ChatStreamEvent>> ) -> Result<tokio::sync::mpsc::Receiver<ChatStreamEvent>>
where where
@@ -814,7 +817,7 @@ impl InsightChatService {
let (tx, rx) = tokio::sync::mpsc::channel::<ChatStreamEvent>(64); let (tx, rx) = tokio::sync::mpsc::channel::<ChatStreamEvent>(64);
let svc = self.clone(); let svc = self.clone();
tokio::spawn(async move { 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 { if let Err(e) = result {
let _ = tx.send(ChatStreamEvent::Error(format!("{}", e))).await; 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)); span.set_attribute(KeyValue::new("library_id", library_id as i64));
let result = svc let result = svc
.run_streaming_turn_with_entry(req, entry_clone.clone()) .run_streaming_turn_with_entry(req, entry_clone.clone(), &mut span)
.await; .await;
if let Err(ref e) = result { if let Err(ref e) = result {
span.set_attribute(KeyValue::new("status", "error")); span.set_attribute(KeyValue::new("status", "error"));
@@ -882,6 +885,7 @@ impl InsightChatService {
self: Arc<Self>, self: Arc<Self>,
req: ChatTurnRequest, req: ChatTurnRequest,
entry: Arc<TurnEntry>, entry: Arc<TurnEntry>,
turn_span: &mut opentelemetry::global::BoxedSpan,
) -> Result<()> { ) -> Result<()> {
if req.user_message.trim().is_empty() { if req.user_message.trim().is_empty() {
bail!("user_message must not be empty"); bail!("user_message must not be empty");
@@ -911,11 +915,11 @@ impl InsightChatService {
if req.regenerate || existing_insight.is_none() { if req.regenerate || existing_insight.is_none() {
return self return self
.run_bootstrap_streaming_with_entry(req, normalized, entry) .run_bootstrap_streaming_with_entry(req, normalized, entry, turn_span)
.await; .await;
} }
let insight = existing_insight.expect("just checked Some above"); 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 .await
} }
@@ -926,6 +930,7 @@ impl InsightChatService {
normalized: String, normalized: String,
insight: crate::database::models::PhotoInsight, insight: crate::database::models::PhotoInsight,
entry: Arc<TurnEntry>, entry: Arc<TurnEntry>,
turn_span: &mut opentelemetry::global::BoxedSpan,
) -> Result<()> { ) -> Result<()> {
let active_persona = req let active_persona = req
.persona_id .persona_id
@@ -981,6 +986,12 @@ impl InsightChatService {
}; };
let backend = self.generator.resolve_backend(kind, &overrides).await?; let backend = self.generator.resolve_backend(kind, &overrides).await?;
let model_used = backend.model().to_string(); 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 let local_first_user_has_image = messages
.iter() .iter()
@@ -1030,6 +1041,7 @@ impl InsightChatService {
&active_persona, &active_persona,
max_iterations, max_iterations,
&entry, &entry,
&turn_cx,
) )
.await?; .await?;
let AgenticLoopOutcome { let AgenticLoopOutcome {
@@ -1146,6 +1158,7 @@ impl InsightChatService {
req: ChatTurnRequest, req: ChatTurnRequest,
normalized: String, normalized: String,
entry: Arc<TurnEntry>, entry: Arc<TurnEntry>,
turn_span: &mut opentelemetry::global::BoxedSpan,
) -> Result<()> { ) -> Result<()> {
let active_persona = req let active_persona = req
.persona_id .persona_id
@@ -1171,11 +1184,21 @@ impl InsightChatService {
}; };
let backend = self.generator.resolve_backend(kind, &overrides).await?; let backend = self.generator.resolve_backend(kind, &overrides).await?;
let model_used = backend.model().to_string(); 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 image_base64: Option<String> = self.generator.load_image_as_base64(&normalized).ok();
let exif = self.generator.fetch_exif(&normalized); 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 let gps = exif
.as_ref() .as_ref()
.and_then(|e| match (e.gps_latitude, e.gps_longitude) { .and_then(|e| match (e.gps_latitude, e.gps_longitude) {
@@ -1185,7 +1208,7 @@ impl InsightChatService {
let visual_block = if !backend.images_inline { let visual_block = if !backend.images_inline {
match image_base64.as_deref() { 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) => { Ok(desc) => {
format!("Visual description (from local vision model):\n{}\n", desc) format!("Visual description (from local vision model):\n{}\n", desc)
} }
@@ -1240,6 +1263,7 @@ impl InsightChatService {
&active_persona, &active_persona,
max_iterations, max_iterations,
&entry, &entry,
&turn_cx,
) )
.await?; .await?;
let AgenticLoopOutcome { let AgenticLoopOutcome {
@@ -1328,6 +1352,7 @@ impl InsightChatService {
active_persona: &str, active_persona: &str,
max_iterations: usize, max_iterations: usize,
entry: &Arc<TurnEntry>, entry: &Arc<TurnEntry>,
cx: &opentelemetry::Context,
) -> Result<AgenticLoopOutcome> { ) -> Result<AgenticLoopOutcome> {
crate::ai::insight_chat::run_streaming_agentic_loop_with_entry( crate::ai::insight_chat::run_streaming_agentic_loop_with_entry(
&self.generator, &self.generator,
@@ -1340,6 +1365,7 @@ impl InsightChatService {
active_persona, active_persona,
max_iterations, max_iterations,
entry, entry,
cx,
) )
.await .await
} }
@@ -1347,6 +1373,7 @@ impl InsightChatService {
async fn run_streaming_turn( async fn run_streaming_turn(
self: Arc<Self>, self: Arc<Self>,
req: ChatTurnRequest, req: ChatTurnRequest,
cx: opentelemetry::Context,
tx: tokio::sync::mpsc::Sender<ChatStreamEvent>, tx: tokio::sync::mpsc::Sender<ChatStreamEvent>,
) -> Result<()> { ) -> Result<()> {
if req.user_message.trim().is_empty() { if req.user_message.trim().is_empty() {
@@ -1376,17 +1403,17 @@ impl InsightChatService {
// library bootstraps cleanly, even when another library has // library bootstraps cleanly, even when another library has
// an insight for the same rel_path. // an insight for the same rel_path.
let existing_insight = { 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"); 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))? .map_err(|e| anyhow!("failed to load insight: {:?}", e))?
}; };
if req.regenerate || existing_insight.is_none() { 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"); 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 .await
} }
@@ -1398,6 +1425,7 @@ impl InsightChatService {
&self, &self,
req: ChatTurnRequest, req: ChatTurnRequest,
normalized: String, normalized: String,
cx: opentelemetry::Context,
insight: crate::database::models::PhotoInsight, insight: crate::database::models::PhotoInsight,
tx: tokio::sync::mpsc::Sender<ChatStreamEvent>, tx: tokio::sync::mpsc::Sender<ChatStreamEvent>,
) -> Result<()> { ) -> Result<()> {
@@ -1495,6 +1523,7 @@ impl InsightChatService {
&active_persona, &active_persona,
max_iterations, max_iterations,
&tx, &tx,
&cx,
) )
.await?; .await?;
let AgenticLoopOutcome { let AgenticLoopOutcome {
@@ -1606,6 +1635,7 @@ impl InsightChatService {
&self, &self,
req: ChatTurnRequest, req: ChatTurnRequest,
normalized: String, normalized: String,
cx: opentelemetry::Context,
tx: tokio::sync::mpsc::Sender<ChatStreamEvent>, tx: tokio::sync::mpsc::Sender<ChatStreamEvent>,
) -> Result<()> { ) -> Result<()> {
let active_persona = req let active_persona = req
@@ -1644,7 +1674,12 @@ impl InsightChatService {
// would otherwise default to today's date and miss every // would otherwise default to today's date and miss every
// historical photo). // historical photo).
let exif = self.generator.fetch_exif(&normalized); 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 let gps = exif
.as_ref() .as_ref()
.and_then(|e| match (e.gps_latitude, e.gps_longitude) { .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. // images_inline backends send images directly to the chat model.
let visual_block = if !backend.images_inline { let visual_block = if !backend.images_inline {
match image_base64.as_deref() { 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) => { Ok(desc) => {
format!("Visual description (from local vision model):\n{}\n", desc) format!("Visual description (from local vision model):\n{}\n", desc)
} }
@@ -1724,6 +1759,7 @@ impl InsightChatService {
&active_persona, &active_persona,
max_iterations, max_iterations,
&tx, &tx,
&cx,
) )
.await?; .await?;
let AgenticLoopOutcome { let AgenticLoopOutcome {
@@ -1806,6 +1842,7 @@ impl InsightChatService {
active_persona: &str, active_persona: &str,
max_iterations: usize, max_iterations: usize,
tx: &tokio::sync::mpsc::Sender<ChatStreamEvent>, tx: &tokio::sync::mpsc::Sender<ChatStreamEvent>,
cx: &opentelemetry::Context,
) -> Result<AgenticLoopOutcome> { ) -> Result<AgenticLoopOutcome> {
let mut tool_calls_made = 0usize; let mut tool_calls_made = 0usize;
let mut iterations_used = 0usize; let mut iterations_used = 0usize;
@@ -1824,7 +1861,7 @@ impl InsightChatService {
let mut stream = backend let mut stream = backend
.chat() .chat()
.chat_with_tools_stream(messages.clone(), tools.clone()) .chat_with_tools_stream(cx, messages.clone(), tools.clone())
.await?; .await?;
let mut final_message: Option<ChatMessage> = None; let mut final_message: Option<ChatMessage> = None;
@@ -1918,7 +1955,7 @@ impl InsightChatService {
let synthetic_idx = push_synthetic_final_prompt(messages); let synthetic_idx = push_synthetic_final_prompt(messages);
let mut stream = backend let mut stream = backend
.chat() .chat()
.chat_with_tools_stream(messages.clone(), vec![]) .chat_with_tools_stream(cx, messages.clone(), vec![])
.await?; .await?;
let mut final_message: Option<ChatMessage> = None; let mut final_message: Option<ChatMessage> = None;
while let Some(ev) = stream.next().await { 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 /// Resolve a human-readable `YYYY-MM-DD` date string for the photo
/// context block. Waterfall: EXIF `date_taken` → filename pattern → /// context block (ADR-0001). Waterfall: the row's display wall (new
/// `None`. The fs-time fallback that `generate_agentic_insight_for_photo` /// 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 /// uses is intentionally NOT applied here — for chat we'd rather show
/// "unknown" than a misleading inode mtime as the photo's date. /// "unknown" than a misleading inode mtime as the photo's date.
fn resolve_date_taken_for_context( fn resolve_date_taken_for_context(
exif: &Option<crate::database::models::ImageExif>, exif: &Option<crate::database::models::ImageExif>,
file_path: &str, file_path: &str,
display_zone: chrono_tz::Tz,
server_zone: Option<chrono_tz::Tz>,
) -> Option<String> { ) -> Option<String> {
let from_exif = exif if let Some(e) = exif
.as_ref() && let Some(wall) = crate::date_view::row_display_wall(e, display_zone, server_zone)
.and_then(|e| e.date_taken) {
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0)) return Some(wall.format("%Y-%m-%d").to_string());
.map(|dt| dt.format("%Y-%m-%d").to_string());
if from_exif.is_some() {
return from_exif;
} }
crate::memories::extract_date_from_filename(file_path) crate::memories::extract_date_from_filename(file_path).and_then(|fd| {
.map(|fd| fd.legacy_datetime().format("%Y-%m-%d").to_string()) 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. /// 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, active_persona: &str,
max_iterations: usize, max_iterations: usize,
entry: &Arc<TurnEntry>, entry: &Arc<TurnEntry>,
cx: &opentelemetry::Context,
) -> Result<AgenticLoopOutcome> { ) -> Result<AgenticLoopOutcome> {
let mut tool_calls_made = 0usize; let mut tool_calls_made = 0usize;
let mut iterations_used = 0usize; let mut iterations_used = 0usize;
@@ -2318,7 +2365,7 @@ pub async fn run_streaming_agentic_loop_with_entry(
let mut stream = backend let mut stream = backend
.chat() .chat()
.chat_with_tools_stream(messages.clone(), tools.clone()) .chat_with_tools_stream(cx, messages.clone(), tools.clone())
.await?; .await?;
let mut final_message: Option<ChatMessage> = None; 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 synthetic_idx = push_synthetic_final_prompt(messages);
let mut stream = backend let mut stream = backend
.chat() .chat()
.chat_with_tools_stream(messages.clone(), vec![]) .chat_with_tools_stream(cx, messages.clone(), vec![])
.await?; .await?;
let mut final_message: Option<ChatMessage> = None; let mut final_message: Option<ChatMessage> = None;
while let Some(ev) = stream.next().await { while let Some(ev) = stream.next().await {
@@ -3385,7 +3432,12 @@ mod tests {
original_camera_offset_minutes: None, original_camera_offset_minutes: None,
original_date_taken_tz_source: 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")); assert_eq!(out.as_deref(), Some("2021-08-15"));
} }
@@ -3394,13 +3446,157 @@ mod tests {
// memories::extract_date_from_filename requires date+time in // memories::extract_date_from_filename requires date+time in
// the filename — date-only patterns aren't matched. Use the // the filename — date-only patterns aren't matched. Use the
// canonical screenshot pattern for the regression case. // 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")); assert_eq!(out.as_deref(), Some("2014-06-01"));
} }
#[test] #[test]
fn date_taken_for_context_returns_none_when_neither_source() { 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()); 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 anyhow::Result;
use base64::Engine as _; use base64::Engine as _;
use chrono::{DateTime, Local, NaiveDate, Utc}; use chrono::{DateTime, Local, NaiveDate, TimeZone, Utc};
use image::ImageFormat; use image::ImageFormat;
use opentelemetry::KeyValue; use opentelemetry::KeyValue;
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
use serde::Deserialize; use serde::Deserialize;
use std::fs::File; use std::fs::File;
use std::io::Cursor; use std::io::Cursor;
use std::str::FromStr;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use crate::ai::apollo_client::{ApolloClient, ApolloPlace}; use crate::ai::apollo_client::{ApolloClient, ApolloPlace};
@@ -23,7 +24,7 @@ use crate::database::{
LocationHistoryDao, SearchHistoryDao, LocationHistoryDao, SearchHistoryDao,
}; };
use crate::libraries::Library; 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::otel::global_tracer;
use crate::tags::TagDao; use crate::tags::TagDao;
use crate::utils::{earliest_fs_time, normalize_path, retry_with_backoff, retry_with_backoff_if}; 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). // the per-persona "reviewed-only" strict-mode toggle).
persona_dao: Arc<Mutex<Box<dyn crate::database::PersonaDao>>>, 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>, libraries: Vec<Library>,
} }
@@ -201,6 +206,7 @@ impl InsightGenerator {
face_dao: Arc<Mutex<Box<dyn crate::faces::FaceDao>>>, face_dao: Arc<Mutex<Box<dyn crate::faces::FaceDao>>>,
knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>>, knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>>,
persona_dao: Arc<Mutex<Box<dyn crate::database::PersonaDao>>>, persona_dao: Arc<Mutex<Box<dyn crate::database::PersonaDao>>>,
user_ai_prefs_dao: Arc<Mutex<Box<dyn crate::database::UserAiPrefsDao>>>,
libraries: Vec<Library>, libraries: Vec<Library>,
) -> Self { ) -> Self {
Self { Self {
@@ -219,10 +225,86 @@ impl InsightGenerator {
face_dao, face_dao,
knowledge_dao, knowledge_dao,
persona_dao, persona_dao,
user_ai_prefs_dao,
libraries, 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 /// Accessor for the insight DAO (used by async job completion to
/// look up the stored insight id). /// look up the stored insight id).
pub fn insight_dao(&self) -> &Arc<Mutex<Box<dyn InsightDao>>> { 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 // (`LLM_BACKEND` switch). Must match the backend that populated the
// daily-summary embeddings or similarity search will be garbage. // daily-summary embeddings or similarity search will be garbage.
let query_embedding = 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 // Search for similar daily summaries with time-based weighting
// This prioritizes summaries temporally close to the query date // 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 // Must use the same backend that populated the daily-summary
// embeddings or similarity search is garbage (see embed_one docs). // embeddings or similarity search is garbage (see embed_one docs).
let query_embedding = 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 let mut summary_dao = self
.daily_summary_dao .daily_summary_dao
@@ -716,7 +800,9 @@ impl InsightGenerator {
let calendar_cx = parent_cx.with_span(span); let calendar_cx = parent_cx.with_span(span);
let query_embedding = if let Some(loc) = location { 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), Ok(emb) => Some(emb),
Err(e) => { Err(e) => {
log::warn!("Failed to generate embedding for location '{}': {}", loc, e); log::warn!("Failed to generate embedding for location '{}': {}", loc, e);
@@ -887,18 +973,23 @@ impl InsightGenerator {
) )
}; };
let query_embedding = let query_embedding = match crate::ai::embed_query(
match crate::ai::embed_query(&self.ollama, self.llamacpp.as_deref(), &query_text).await &search_cx,
{ &self.ollama,
Ok(emb) => emb, self.llamacpp.as_deref(),
Err(e) => { &query_text,
log::warn!("Failed to generate search embedding: {}", e); )
search_cx.span().set_status(Status::Error { .await
description: e.to_string().into(), {
}); Ok(emb) => emb,
return Ok(None); 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 searches = {
let mut dao = self let mut dao = self
@@ -1055,35 +1146,8 @@ impl InsightGenerator {
.map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))? .map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))?
}; };
// Get full timestamp for proximity-based message filtering // True-instant anchor + prompt-facing display wall (ADR-0001).
let timestamp = if let Some(ts) = exif.as_ref().and_then(|e| e.date_taken) { let (timestamp, date_taken) = self.resolve_date_anchor(&exif, &file_path, "insight");
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());
// 3. Extract contact name from file path // 3. Extract contact name from file path
let contact = Self::extract_contact_from_path(&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) // Generate brief photo description for RAG enrichment (vision models only)
let photo_description: Option<String> = if let Some(ref img_b64) = image_base64 { 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) => { Ok(desc) => {
log::info!("Photo description for RAG enrichment: {}", desc); log::info!("Photo description for RAG enrichment: {}", desc);
Some(desc) Some(desc)
@@ -1268,7 +1335,7 @@ impl InsightGenerator {
if !immediate_messages.is_empty() { if !immediate_messages.is_empty() {
// Step 2: Extract topics from immediate messages to enrich RAG query // Step 2: Extract topics from immediate messages to enrich RAG query
let topics = self let topics = self
.extract_topics_from_messages(&immediate_messages, &ollama_client) .extract_topics_from_messages(&immediate_messages, &ollama_client, &insight_cx)
.await; .await;
log::info!("Extracted topics for query enrichment: {:?}", topics); log::info!("Extracted topics for query enrichment: {:?}", topics);
@@ -1324,6 +1391,7 @@ impl InsightGenerator {
&immediate_messages, &immediate_messages,
&ollama_client, &ollama_client,
custom_system_prompt.as_deref(), custom_system_prompt.as_deref(),
&insight_cx,
) )
.await .await
.unwrap_or_else(|| String::from("No immediate context")); .unwrap_or_else(|| String::from("No immediate context"));
@@ -1333,6 +1401,7 @@ impl InsightGenerator {
&historical_messages, &historical_messages,
&ollama_client, &ollama_client,
custom_system_prompt.as_deref(), custom_system_prompt.as_deref(),
&insight_cx,
) )
.await .await
.unwrap_or_else(|| String::from("No historical context")); .unwrap_or_else(|| String::from("No historical context"));
@@ -1351,6 +1420,7 @@ impl InsightGenerator {
&immediate_messages, &immediate_messages,
&ollama_client, &ollama_client,
custom_system_prompt.as_deref(), custom_system_prompt.as_deref(),
&insight_cx,
) )
.await; .await;
} }
@@ -1361,6 +1431,7 @@ impl InsightGenerator {
&immediate_messages, &immediate_messages,
&ollama_client, &ollama_client,
custom_system_prompt.as_deref(), custom_system_prompt.as_deref(),
&insight_cx,
) )
.await; .await;
} }
@@ -1386,6 +1457,7 @@ impl InsightGenerator {
&rag_messages, &rag_messages,
&ollama_client, &ollama_client,
custom_system_prompt.as_deref(), custom_system_prompt.as_deref(),
&insight_cx,
) )
.await; .await;
} }
@@ -1418,7 +1490,7 @@ impl InsightGenerator {
if !sms_messages.is_empty() { if !sms_messages.is_empty() {
match self match self
.sms_client .sms_client
.summarize_context(&sms_messages, &ollama_client) .summarize_context(&sms_messages, &ollama_client, &insight_cx)
.await .await
{ {
Ok(summary) => { Ok(summary) => {
@@ -1502,6 +1574,7 @@ impl InsightGenerator {
// 10. Generate summary first, then derive title from the summary // 10. Generate summary first, then derive title from the summary
let summary = ollama_client let summary = ollama_client
.generate_photo_summary( .generate_photo_summary(
&insight_cx,
date_taken, date_taken,
location.as_deref(), location.as_deref(),
contact.as_deref(), contact.as_deref(),
@@ -1512,7 +1585,7 @@ impl InsightGenerator {
.await?; .await?;
let title = ollama_client 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?; .await?;
log::info!("Generated title: {}", title); log::info!("Generated title: {}", title);
@@ -1579,6 +1652,7 @@ impl InsightGenerator {
&self, &self,
messages: &[crate::ai::SmsMessage], messages: &[crate::ai::SmsMessage],
ollama: &OllamaClient, ollama: &OllamaClient,
cx: &opentelemetry::Context,
) -> Vec<String> { ) -> Vec<String> {
if messages.is_empty() { if messages.is_empty() {
return Vec::new(); return Vec::new();
@@ -1614,7 +1688,7 @@ Return ONLY the comma-separated list, nothing else."#,
); );
match ollama 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 .await
{ {
Ok(response) => { Ok(response) => {
@@ -1649,6 +1723,7 @@ Return ONLY the comma-separated list, nothing else."#,
messages: &[String], messages: &[String],
ollama: &OllamaClient, ollama: &OllamaClient,
custom_system: Option<&str>, custom_system: Option<&str>,
cx: &opentelemetry::Context,
) -> Option<String> { ) -> Option<String> {
if messages.is_empty() { if messages.is_empty() {
return None; return None;
@@ -1669,7 +1744,7 @@ Return ONLY the summary, nothing else."#,
let system = custom_system let system = custom_system
.unwrap_or("You are a context summarization assistant. Be concise and factual."); .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), Ok(summary) => Some(summary),
Err(e) => { Err(e) => {
log::warn!("Failed to summarize messages: {}", e); log::warn!("Failed to summarize messages: {}", e);
@@ -1685,6 +1760,7 @@ Return ONLY the summary, nothing else."#,
messages: &[crate::ai::SmsMessage], messages: &[crate::ai::SmsMessage],
ollama: &OllamaClient, ollama: &OllamaClient,
custom_system: Option<&str>, custom_system: Option<&str>,
cx: &opentelemetry::Context,
) -> Option<String> { ) -> Option<String> {
if messages.is_empty() { if messages.is_empty() {
return None; 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.", "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), Ok(summary) => Some(summary),
Err(e) => { Err(e) => {
log::warn!("Failed to summarize immediate context: {}", 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_file_tags" => self.tool_get_file_tags(arguments, cx).await,
"get_faces_in_photo" => self.tool_get_faces_in_photo(arguments, cx).await, "get_faces_in_photo" => self.tool_get_faces_in_photo(arguments, cx).await,
"describe_photo" => { "describe_photo" => {
self.tool_describe_photo(backend.local(), image_base64) self.tool_describe_photo(backend.local(), image_base64, cx)
.await .await
} }
"reverse_geocode" => self.tool_reverse_geocode(arguments).await, "reverse_geocode" => self.tool_reverse_geocode(arguments).await,
@@ -1825,7 +1901,7 @@ Return ONLY the summary, nothing else."#,
&self, &self,
args: &serde_json::Value, args: &serde_json::Value,
local: &dyn LlmClient, local: &dyn LlmClient,
_cx: &opentelemetry::Context, cx: &opentelemetry::Context,
) -> String { ) -> String {
let query = match args.get("query").and_then(|v| v.as_str()) { let query = match args.get("query").and_then(|v| v.as_str()) {
Some(q) => q.to_string(), Some(q) => q.to_string(),
@@ -1889,7 +1965,10 @@ Return ONLY the summary, nothing else."#,
}; };
let final_results = if rerank_enabled && results.len() > limit { 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, Ok(reordered) => reordered,
Err(e) => { Err(e) => {
log::warn!("rerank failed, using vector order: {}", 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. /// tool_result. On parse failure we fall back to the input order.
async fn rerank_with_llm( async fn rerank_with_llm(
&self, &self,
cx: &opentelemetry::Context,
query: &str, query: &str,
candidates: &[String], candidates: &[String],
limit: usize, limit: usize,
@@ -1955,7 +2035,7 @@ Return ONLY the summary, nothing else."#,
let started = std::time::Instant::now(); let started = std::time::Instant::now();
let system = let system =
Some("You are a terse relevance ranker. You output only numbers separated by commas."); 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!( log::info!(
"rerank: finished in {} ms (prompt={} chars)", "rerank: finished in {} ms (prompt={} chars)",
started.elapsed().as_millis(), started.elapsed().as_millis(),
@@ -2565,10 +2645,11 @@ Return ONLY the summary, nothing else."#,
&self, &self,
local: &dyn LlmClient, local: &dyn LlmClient,
image_base64: &Option<String>, image_base64: &Option<String>,
cx: &opentelemetry::Context,
) -> String { ) -> String {
log::info!("tool_describe_photo: generating visual description"); log::info!("tool_describe_photo: generating visual description");
match image_base64 { match image_base64 {
Some(img) => match local.describe_image(img).await { Some(img) => match local.describe_image(cx, img).await {
Ok(desc) => desc, Ok(desc) => desc,
Err(e) => format!("Error describing photo: {}", e), Err(e) => format!("Error describing photo: {}", e),
}, },
@@ -2976,6 +3057,7 @@ Return ONLY the summary, nothing else."#,
// configured local backend. // configured local backend.
let embed_text = format!("{} {}", name, description); let embed_text = format!("{} {}", name, description);
let embedding: Option<Vec<u8>> = match crate::ai::embed_document( let embedding: Option<Vec<u8>> = match crate::ai::embed_document(
cx,
&self.ollama, &self.ollama,
self.llamacpp.as_deref(), self.llamacpp.as_deref(),
&embed_text, &embed_text,
@@ -4147,34 +4229,9 @@ Return ONLY the summary, nothing else."#,
.map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))? .map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))?
}; };
// 4. Extract timestamp and contact // 4. Extract date and contact. The agentic prompt only needs the
let timestamp = if let Some(ts) = exif.as_ref().and_then(|e| e.date_taken) { // display wall (ADR-0001) — its proximity windows are tool-driven.
ts let (_, date_taken) = self.resolve_date_anchor(&exif, &file_path, "agentic insight");
} 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());
let contact = Self::extract_contact_from_path(&file_path); let contact = Self::extract_contact_from_path(&file_path);
log::info!("Agentic: date_taken={}, contact={:?}", date_taken, contact); 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. // the local backend so non-text work stays off OpenRouter.
let inlined_visual_description: Option<String> = if !backend.images_inline { let inlined_visual_description: Option<String> = if !backend.images_inline {
match image_base64.as_deref() { 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) => { Ok(desc) => {
log::info!("{}: vision describe succeeded ({} chars)", kind, desc.len()); log::info!("{}: vision describe succeeded ({} chars)", kind, desc.len());
Some(desc) Some(desc)
@@ -4367,7 +4424,7 @@ Return ONLY the summary, nothing else."#,
let (response, prompt_tokens, eval_tokens) = backend let (response, prompt_tokens, eval_tokens) = backend
.chat() .chat()
.chat_with_tools(messages.clone(), tools.clone()) .chat_with_tools(&loop_cx, messages.clone(), tools.clone())
.await?; .await?;
last_prompt_eval_count = prompt_tokens; last_prompt_eval_count = prompt_tokens;
@@ -4436,7 +4493,7 @@ Return ONLY the summary, nothing else."#,
))); )));
let (final_response, prompt_tokens, eval_tokens) = backend let (final_response, prompt_tokens, eval_tokens) = backend
.chat() .chat()
.chat_with_tools(messages.clone(), tools.clone()) .chat_with_tools(&loop_cx, messages.clone(), tools.clone())
.await?; .await?;
last_prompt_eval_count = prompt_tokens; last_prompt_eval_count = prompt_tokens;
last_eval_count = eval_tokens; last_eval_count = eval_tokens;
@@ -4469,7 +4526,7 @@ Return ONLY the summary, nothing else."#,
} }
let (resp2, pt2, et2) = backend let (resp2, pt2, et2) = backend
.chat() .chat()
.chat_with_tools(messages.clone(), vec![]) .chat_with_tools(&loop_cx, messages.clone(), vec![])
.await?; .await?;
last_prompt_eval_count = pt2; last_prompt_eval_count = pt2;
last_eval_count = et2; last_eval_count = et2;
@@ -4607,6 +4664,7 @@ Return ONLY the summary, nothing else."#,
tools: Vec<Tool>, tools: Vec<Tool>,
max_iter: usize, max_iter: usize,
) -> Result<String> { ) -> Result<String> {
let loop_cx = opentelemetry::Context::current();
let mut final_content = String::new(); let mut final_content = String::new();
for iteration in 0..max_iter { for iteration in 0..max_iter {
@@ -4614,7 +4672,7 @@ Return ONLY the summary, nothing else."#,
let (response, _prompt_tokens, _eval_tokens) = backend let (response, _prompt_tokens, _eval_tokens) = backend
.chat() .chat()
.chat_with_tools(messages.clone(), tools.clone()) .chat_with_tools(&loop_cx, messages.clone(), tools.clone())
.await?; .await?;
// Sanitize tool call arguments before pushing back into history. // Sanitize tool call arguments before pushing back into history.
@@ -4656,7 +4714,7 @@ Return ONLY the summary, nothing else."#,
"", "",
0, 0,
"", "",
&opentelemetry::Context::new(), &loop_cx,
) )
.await; .await;
messages.push(ChatMessage::tool_result(result)); messages.push(ChatMessage::tool_result(result));
@@ -4680,7 +4738,7 @@ Return ONLY the summary, nothing else."#,
)); ));
let (final_response, _, _) = backend let (final_response, _, _) = backend
.chat() .chat()
.chat_with_tools(messages.clone(), tools.clone()) .chat_with_tools(&loop_cx, messages.clone(), tools.clone())
.await?; .await?;
// Same bounded tool round as the insight loop: execute any last // Same bounded tool round as the insight loop: execute any last
// tool call, then force a tool-less turn for the actual answer. // tool call, then force a tool-less turn for the actual answer.
@@ -4698,14 +4756,14 @@ Return ONLY the summary, nothing else."#,
"", "",
0, 0,
"", "",
&opentelemetry::Context::new(), &loop_cx,
) )
.await; .await;
messages.push(ChatMessage::tool_result(result)); messages.push(ChatMessage::tool_result(result));
} }
let (resp2, _, _) = backend let (resp2, _, _) = backend
.chat() .chat()
.chat_with_tools(messages.clone(), vec![]) .chat_with_tools(&loop_cx, messages.clone(), vec![])
.await?; .await?;
final_content = resp2.content; final_content = resp2.content;
} else { } else {
+111 -26
View File
@@ -20,8 +20,11 @@
// insight_generator / insight_chat. // insight_generator / insight_chat.
#![allow(dead_code)] #![allow(dead_code)]
use anyhow::{Context, Result, anyhow, bail}; use anyhow::Context as _;
use anyhow::{Result, anyhow, bail};
use async_trait::async_trait; use async_trait::async_trait;
use opentelemetry::trace::{Span, Status};
use opentelemetry::{Context, KeyValue};
use reqwest::Client; use reqwest::Client;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -486,6 +489,7 @@ impl LlamaCppClient {
async fn chat_completion_with_model( async fn chat_completion_with_model(
&self, &self,
model: &str, model: &str,
mut span: Option<&mut opentelemetry::global::BoxedSpan>,
messages: Vec<ChatMessage>, messages: Vec<ChatMessage>,
tools: Vec<Tool>, tools: Vec<Tool>,
) -> Result<(ChatMessage, Option<i32>, Option<i32>)> { ) -> Result<(ChatMessage, Option<i32>, Option<i32>)> {
@@ -517,19 +521,31 @@ impl LlamaCppClient {
// Wait for any TTS synthesis to release the GPU before the request // Wait for any TTS synthesis to release the GPU before the request
// timeout starts (see ai::gpu). // timeout starts (see ai::gpu).
let _gpu = crate::ai::gpu::llm_lease().await; let _gpu = crate::ai::gpu::llm_lease().await;
let resp = self let mut request = self.client.post(&url).json(&Value::Object(body));
.client if let Some(s) = span.as_mut() {
.post(&url) request = crate::otel::with_traceparent(request, s.span_context());
.json(&Value::Object(body)) }
.send() let resp = match request.send().await {
.await Ok(r) => r,
.with_context(|| format!("POST {} failed", url))?; 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() { if !resp.status().is_success() {
let status = resp.status(); 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(); let body = resp.text().await.unwrap_or_default();
bail!("llama-swap chat request failed: {} — {}", status, body); 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 parsed: Value = resp.json().await.context("parsing chat response")?;
let choice = parsed let choice = parsed
@@ -562,14 +578,35 @@ impl LlamaCppClient {
log_timings(&parsed, prompt_tokens, completion_tokens); 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)) 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] #[async_trait]
impl LlmClient for LlamaCppClient { impl LlmClient for LlamaCppClient {
async fn generate( async fn generate(
&self, &self,
cx: &Context,
prompt: &str, prompt: &str,
system: Option<&str>, system: Option<&str>,
images: Option<Vec<String>>, images: Option<Vec<String>>,
@@ -582,12 +619,15 @@ impl LlmClient for LlamaCppClient {
user.images = images; user.images = images;
messages.push(user); 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) Ok(reply.content)
} }
async fn chat_with_tools( async fn chat_with_tools(
&self, &self,
cx: &Context,
messages: Vec<ChatMessage>, messages: Vec<ChatMessage>,
tools: Vec<Tool>, tools: Vec<Tool>,
) -> Result<(ChatMessage, Option<i32>, Option<i32>)> { ) -> Result<(ChatMessage, Option<i32>, Option<i32>)> {
@@ -597,15 +637,25 @@ impl LlmClient for LlamaCppClient {
messages.len(), messages.len(),
tools.len() tools.len()
); );
self.chat_completion_with_model(&self.primary_model.clone(), messages, tools) let mut span = self.start_request_span(cx, "chat", &self.primary_model);
.await self.chat_completion_with_model(
&self.primary_model.clone(),
Some(&mut span),
messages,
tools,
)
.await
} }
async fn chat_with_tools_stream( async fn chat_with_tools_stream(
&self, &self,
cx: &Context,
messages: Vec<ChatMessage>, messages: Vec<ChatMessage>,
tools: Vec<Tool>, tools: Vec<Tool>,
) -> Result<BoxStream<'static, Result<LlmStreamEvent>>> { ) -> 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 url = format!("{}/chat/completions", self.base_url);
let roles: Vec<&str> = messages.iter().map(|m| m.role.as_str()).collect(); let roles: Vec<&str> = messages.iter().map(|m| m.role.as_str()).collect();
log::debug!( log::debug!(
@@ -639,19 +689,24 @@ impl LlmClient for LlamaCppClient {
// timeout starts (see ai::gpu). The guard is moved into the stream // timeout starts (see ai::gpu). The guard is moved into the stream
// below so the lease spans the whole generation, not just the send. // below so the lease spans the whole generation, not just the send.
let gpu = crate::ai::gpu::llm_lease().await; let gpu = crate::ai::gpu::llm_lease().await;
let resp = self let mut request = self.client.post(&url).json(&Value::Object(body));
.client request = crate::otel::with_traceparent(request, span.span_context());
.post(&url) let resp = request
.json(&Value::Object(body))
.send() .send()
.await .await
.with_context(|| format!("POST {} failed", url))?; .with_context(|| format!("POST {} failed", url))?;
if !resp.status().is_success() { if !resp.status().is_success() {
let status = resp.status(); 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(); let body = resp.text().await.unwrap_or_default();
bail!("llama-swap stream request failed: {} — {}", status, body); 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 byte_stream = resp.bytes_stream();
let stream = async_stream::stream! { let stream = async_stream::stream! {
@@ -760,6 +815,13 @@ impl LlmClient for LlamaCppClient {
tool_calls, tool_calls,
images: None, 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 { yield Ok(LlmStreamEvent::Done {
message, message,
prompt_eval_count: prompt_tokens, prompt_eval_count: prompt_tokens,
@@ -770,7 +832,8 @@ impl LlmClient for LlamaCppClient {
Ok(Box::pin(stream)) 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 // Deliberately NO GPU lease: the embed slot sits in llama-swap's
// always-resident group and never participates in a model swap, so // always-resident group and never participates in a model swap, so
// leasing here would only stall searches behind a queued synthesis. // leasing here would only stall searches behind a queued synthesis.
@@ -780,19 +843,27 @@ impl LlmClient for LlamaCppClient {
"input": texts, "input": texts,
}); });
let resp = self let mut request = self.client.post(&url).json(&body);
.client request = crate::otel::with_traceparent(request, span.span_context());
.post(&url) let resp = match request.send().await {
.json(&body) Ok(r) => r,
.send() Err(e) => {
.await span.set_status(Status::error("llama-swap embedding request failed"));
.with_context(|| format!("POST {} failed", url))?; return Err(e).with_context(|| format!("POST {} failed", url));
}
};
if !resp.status().is_success() { if !resp.status().is_success() {
let status = resp.status(); 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(); let body = resp.text().await.unwrap_or_default();
bail!("llama-swap embedding request failed: {} — {}", status, body); bail!("llama-swap embedding request failed: {} — {}", status, body);
} }
crate::otel::set_http_status(Some(&mut span), resp.status().as_u16());
#[derive(Deserialize)] #[derive(Deserialize)]
struct EmbedResponse { struct EmbedResponse {
@@ -803,11 +874,20 @@ impl LlmClient for LlamaCppClient {
embedding: Vec<f32>, 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()) 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. \ let prompt = "Briefly describe what you see in this image in 1-2 sentences. \
Focus on the people, location, and activity."; Focus on the people, location, and activity.";
let system = "You are a scene description assistant. Be concise and factual."; let system = "You are a scene description assistant. Be concise and factual.";
@@ -823,7 +903,12 @@ impl LlmClient for LlamaCppClient {
]; ];
let (reply, _, _) = self 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?; .await?;
Ok(reply.content) Ok(reply.content)
} }
+12 -2
View File
@@ -1,6 +1,7 @@
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use futures::stream::BoxStream; use futures::stream::BoxStream;
use opentelemetry::Context;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Provider-agnostic surface for LLM backends (Ollama, OpenRouter, …). /// 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` /// when a provider requires it (OpenAI-compatible APIs do), and `images`
/// stays as base64 strings here and is rewritten into content-parts where /// stays as base64 strings here and is rewritten into content-parts where
/// needed. /// 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). // First consumer lands in a later PR (OpenRouter impl + hybrid mode routing).
#[allow(dead_code)] #[allow(dead_code)]
#[async_trait] #[async_trait]
@@ -18,6 +25,7 @@ pub trait LlmClient: Send + Sync {
/// base64 images (ignored by providers without vision support). /// base64 images (ignored by providers without vision support).
async fn generate( async fn generate(
&self, &self,
cx: &Context,
prompt: &str, prompt: &str,
system: Option<&str>, system: Option<&str>,
images: Option<Vec<String>>, images: Option<Vec<String>>,
@@ -27,6 +35,7 @@ pub trait LlmClient: Send + Sync {
/// (which may contain tool_calls) plus optional prompt/eval token counts. /// (which may contain tool_calls) plus optional prompt/eval token counts.
async fn chat_with_tools( async fn chat_with_tools(
&self, &self,
cx: &Context,
messages: Vec<ChatMessage>, messages: Vec<ChatMessage>,
tools: Vec<Tool>, tools: Vec<Tool>,
) -> Result<(ChatMessage, Option<i32>, Option<i32>)>; ) -> Result<(ChatMessage, Option<i32>, Option<i32>)>;
@@ -39,16 +48,17 @@ pub trait LlmClient: Send + Sync {
/// as one `Done` event. /// as one `Done` event.
async fn chat_with_tools_stream( async fn chat_with_tools_stream(
&self, &self,
cx: &Context,
messages: Vec<ChatMessage>, messages: Vec<ChatMessage>,
tools: Vec<Tool>, tools: Vec<Tool>,
) -> Result<BoxStream<'static, Result<LlmStreamEvent>>>; ) -> Result<BoxStream<'static, Result<LlmStreamEvent>>>;
/// Batch embedding generation. Dimensionality is provider/model specific. /// 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 /// One-shot vision description of an image. Used to convert images into
/// plain text for the hybrid-mode conversation flow. /// 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. /// Enumerate available models with their capabilities.
async fn list_models(&self) -> Result<Vec<ModelCapabilities>>; 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 /// pick query vs document — retrieval models treat the two sides
/// differently and an unmarked embed invites prefix-mismatch bugs. /// differently and an unmarked embed invites prefix-mismatch bugs.
pub async fn embed_query(&self, text: &str) -> Result<Vec<f32>> { 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`). /// Embed corpus text (applies `EMBED_DOCUMENT_PREFIX`).
pub async fn embed_document(&self, text: &str) -> Result<Vec<f32>> { 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 /// 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> { pub async fn generate(&self, prompt: &str, system: Option<&str>) -> Result<String> {
if super::local_backend_is_llamacpp() { if super::local_backend_is_llamacpp() {
if let Some(lc) = self.llamacpp.as_deref() { 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!( anyhow::bail!(
"LLM_BACKEND=llamacpp but LlamaCppClient is unconfigured — \ "LLM_BACKEND=llamacpp but LlamaCppClient is unconfigured — \
set LLAMA_SWAP_URL or switch to LLM_BACKEND=ollama" 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 /// 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 /// `Instruct: <task>\nQuery: `. Must pair with the document prefix the
/// corpus was embedded with or similarity degrades. /// corpus was embedded with or similarity degrades.
pub async fn embed_query( pub async fn embed_query(
cx: &opentelemetry::Context,
ollama: &OllamaClient, ollama: &OllamaClient,
llamacpp: Option<&LlamaCppClient>, llamacpp: Option<&LlamaCppClient>,
text: &str, text: &str,
) -> anyhow::Result<Vec<f32>> { ) -> anyhow::Result<Vec<f32>> {
let prefixed = format!("{}{}", embed_prefix("EMBED_QUERY_PREFIX"), text); 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 corpus text (the stored side of retrieval). Applies
/// `EMBED_DOCUMENT_PREFIX` (default empty; nomic v1.5 wants /// `EMBED_DOCUMENT_PREFIX` (default empty; nomic v1.5 wants
/// `search_document: `, Qwen3-Embedding wants none). /// `search_document: `, Qwen3-Embedding wants none).
pub async fn embed_document( pub async fn embed_document(
cx: &opentelemetry::Context,
ollama: &OllamaClient, ollama: &OllamaClient,
llamacpp: Option<&LlamaCppClient>, llamacpp: Option<&LlamaCppClient>,
text: &str, text: &str,
) -> anyhow::Result<Vec<f32>> { ) -> anyhow::Result<Vec<f32>> {
let prefixed = format!("{}{}", embed_prefix("EMBED_DOCUMENT_PREFIX"), text); 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 /// 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 /// configured), else Ollama. See [`local_backend_is_llamacpp`] for the
/// rationale on consistency. /// rationale on consistency.
pub async fn embed_many( pub async fn embed_many(
cx: &opentelemetry::Context,
ollama: &OllamaClient, ollama: &OllamaClient,
llamacpp: Option<&LlamaCppClient>, llamacpp: Option<&LlamaCppClient>,
texts: &[&str], texts: &[&str],
) -> anyhow::Result<Vec<Vec<f32>>> { ) -> anyhow::Result<Vec<Vec<f32>>> {
if local_backend_is_llamacpp() { if local_backend_is_llamacpp() {
if let Some(lc) = 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!( anyhow::bail!(
"LLM_BACKEND=llamacpp but LlamaCppClient is unconfigured — \ "LLM_BACKEND=llamacpp but LlamaCppClient is unconfigured — \
set LLAMA_SWAP_URL or switch to LLM_BACKEND=ollama" 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 /// Embed one string via the configured local backend. Single-text
/// convenience over [`embed_many`]. /// convenience over [`embed_many`].
pub async fn embed_one( pub async fn embed_one(
cx: &opentelemetry::Context,
ollama: &OllamaClient, ollama: &OllamaClient,
llamacpp: Option<&LlamaCppClient>, llamacpp: Option<&LlamaCppClient>,
text: &str, text: &str,
) -> anyhow::Result<Vec<f32>> { ) -> 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() vecs.pop()
.ok_or_else(|| anyhow::anyhow!("embedding backend returned no embeddings")) .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, nl: &str,
tag_vocab: &[(i32, String)], tag_vocab: &[(i32, String)],
today: chrono::NaiveDate, today: chrono::NaiveDate,
cx: &opentelemetry::Context,
) -> Result<StructuredQuery> { ) -> Result<StructuredQuery> {
let system = build_system_prompt(tag_vocab, today); let system = build_system_prompt(tag_vocab, today);
let messages = vec![ChatMessage::system(system), ChatMessage::user(nl)]; 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) 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 async_trait::async_trait;
use chrono::NaiveDate; use chrono::NaiveDate;
use opentelemetry::trace::{Span, Status};
use opentelemetry::{Context, KeyValue};
use reqwest::Client; use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
@@ -365,6 +368,7 @@ impl OllamaClient {
async fn try_generate( async fn try_generate(
&self, &self,
mut span: Option<&mut opentelemetry::global::BoxedSpan>,
url: &str, url: &str,
model: &str, model: &str,
prompt: &str, prompt: &str,
@@ -382,15 +386,30 @@ impl OllamaClient {
think, think,
}; };
let response = self let mut request_builder = self
.client .client
.post(format!("{}/api/generate", url)) .post(format!("{}/api/generate", url))
.json(&request) .json(&request);
.send() if let Some(s) = span.as_mut() {
.await?; 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() { if !response.status().is_success() {
let status = response.status(); 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(); let error_body = response.text().await.unwrap_or_default();
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Ollama request failed: {} - {}", "Ollama request failed: {} - {}",
@@ -398,8 +417,26 @@ impl OllamaClient {
error_body 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( log_chat_metrics(
result.prompt_eval_count, result.prompt_eval_count,
result.prompt_eval_duration, result.prompt_eval_duration,
@@ -409,32 +446,55 @@ impl OllamaClient {
Ok(result.response) Ok(result.response)
} }
pub async fn generate(&self, prompt: &str, system: Option<&str>) -> Result<String> { /// Start the `llm.request` span for an outgoing Ollama call.
self.generate_with_images(prompt, system, None).await 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)] #[allow(dead_code)]
pub async fn generate_no_think(&self, prompt: &str, system: Option<&str>) -> Result<String> { pub async fn generate_no_think(
self.generate_with_options(prompt, system, None, Some(false)) &self,
cx: &Context,
prompt: &str,
system: Option<&str>,
) -> Result<String> {
self.generate_with_options(cx, prompt, system, None, Some(false), "generate")
.await .await
} }
pub async fn generate_with_images( pub async fn generate_with_images(
&self, &self,
cx: &Context,
prompt: &str, prompt: &str,
system: Option<&str>, system: Option<&str>,
images: Option<Vec<String>>, images: Option<Vec<String>>,
) -> Result<String> { ) -> Result<String> {
self.generate_with_options(prompt, system, images, None) self.generate_with_options(cx, prompt, system, images, None, "generate")
.await .await
} }
async fn generate_with_options( async fn generate_with_options(
&self, &self,
cx: &Context,
prompt: &str, prompt: &str,
system: Option<&str>, system: Option<&str>,
images: Option<Vec<String>>, images: Option<Vec<String>>,
think: Option<bool>, think: Option<bool>,
operation: &str,
) -> Result<String> { ) -> Result<String> {
log::debug!("=== Ollama Request ==="); log::debug!("=== Ollama Request ===");
log::debug!("Primary model: {}", self.primary_model); log::debug!("Primary model: {}", self.primary_model);
@@ -447,6 +507,10 @@ impl OllamaClient {
} }
log::debug!("====================="); 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 // Try primary server first with primary model
log::info!( log::info!(
"Attempting to generate with primary server: {} (model: {})", "Attempting to generate with primary server: {} (model: {})",
@@ -455,6 +519,7 @@ impl OllamaClient {
); );
let primary_result = self let primary_result = self
.try_generate( .try_generate(
Some(&mut span),
&self.primary_url, &self.primary_url,
&self.primary_model, &self.primary_model,
prompt, prompt,
@@ -483,8 +548,10 @@ impl OllamaClient {
fallback_url, fallback_url,
fallback_model fallback_model
); );
span.set_attribute(KeyValue::new("llm.model", fallback_model.to_string()));
match self match self
.try_generate( .try_generate(
Some(&mut span),
fallback_url, fallback_url,
fallback_model, fallback_model,
prompt, prompt,
@@ -531,6 +598,7 @@ impl OllamaClient {
/// Generate a title for a single photo based on its generated summary /// Generate a title for a single photo based on its generated summary
pub async fn generate_photo_title( pub async fn generate_photo_title(
&self, &self,
cx: &Context,
summary: &str, summary: &str,
custom_system: Option<&str>, custom_system: Option<&str>,
) -> Result<String> { ) -> 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 system = custom_system.unwrap_or("You are my long term memory assistant. Use only the information provided. Do not invent details.");
let title = self let title = self
.generate_with_images(&prompt, Some(system), None) .generate_with_images(cx, &prompt, Some(system), None)
.await?; .await?;
// Models decorate despite "Return ONLY the title": quotes, bold // Models decorate despite "Return ONLY the title": quotes, bold
// markers, sometimes a "Title:" label. // 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 /// Generate a summary for a single photo based on its context
pub async fn generate_photo_summary( pub async fn generate_photo_summary(
&self, &self,
cx: &Context,
date: NaiveDate, date: NaiveDate,
location: Option<&str>, location: Option<&str>,
contact: 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 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]); 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 .await
} }
/// Generate a brief visual description of a photo for use in RAG query enrichment. /// 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. /// Returns 1-2 sentences describing people, location, and activity visible in the image.
/// Only called when the model has vision capabilities. /// 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. \ let prompt = "Briefly describe what you see in this image in 1-2 sentences. \
Focus on the people, location, and activity."; Focus on the people, location, and activity.";
let system = "You are a scene description assistant. Be concise and factual."; let system = "You are a scene description assistant. Be concise and factual.";
let images = vec![image_base64.to_string()]; let images = vec![image_base64.to_string()];
let description = self let description = self
.generate_with_images(prompt, Some(system), Some(images)) .generate_with_options(
cx,
prompt,
Some(system),
Some(images),
None,
"describe_image",
)
.await?; .await?;
Ok(description.trim().to_string()) 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. /// iteration of the agent loop.
pub async fn chat_with_tools( pub async fn chat_with_tools(
&self, &self,
cx: &Context,
messages: Vec<ChatMessage>, messages: Vec<ChatMessage>,
tools: Vec<Tool>, tools: Vec<Tool>,
) -> Result<(ChatMessage, Option<i32>, Option<i32>)> { ) -> Result<(ChatMessage, Option<i32>, Option<i32>)> {
let mut span = self.start_request_span(cx, "chat", &self.primary_model);
let order = self.attempt_order(); let order = self.attempt_order();
let mut errors: Vec<String> = Vec::new(); 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, url,
model model
); );
if *label == "fallback" {
span.set_attribute(KeyValue::new("llm.model", model.to_string()));
}
match self match self
.try_chat_with_tools(url, messages.clone(), tools.clone()) .try_chat_with_tools(Some(&mut span), url, messages.clone(), tools.clone())
.await .await
{ {
Ok(result) => { 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). /// any, live on the final message).
pub async fn chat_with_tools_stream( pub async fn chat_with_tools_stream(
&self, &self,
cx: &Context,
messages: Vec<ChatMessage>, messages: Vec<ChatMessage>,
tools: Vec<Tool>, tools: Vec<Tool>,
) -> Result<BoxStream<'static, Result<LlmStreamEvent>>> { ) -> Result<BoxStream<'static, Result<LlmStreamEvent>>> {
// Same preference logic as `chat_with_tools`. Only the initial // Same preference logic as `chat_with_tools`. Only the initial
// connection is retried across servers — once the stream begins, // 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 order = self.attempt_order();
let mut last_err: Option<anyhow::Error> = None; let mut last_err: Option<anyhow::Error> = None;
for (label, url, _model) in &order { for (label, url, _model) in &order {
match self match self
.try_chat_with_tools_stream(url, messages.clone(), tools.clone()) .try_chat_with_tools_stream(cx, url, messages.clone(), tools.clone())
.await .await
{ {
Ok(s) => { 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( async fn try_chat_with_tools_stream(
&self, &self,
cx: &Context,
base_url: &str, base_url: &str,
messages: Vec<ChatMessage>, messages: Vec<ChatMessage>,
tools: Vec<Tool>, 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(); 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 { let request_body = OllamaChatRequest {
model, model,
messages: &messages, messages: &messages,
@@ -781,16 +876,26 @@ Analyze the image and use specific details from both the visual content and the
options, options,
}; };
let response = self let response = {
.client let mut rb = self.client.post(&url).json(&request_body);
.post(&url) rb = crate::otel::with_traceparent(rb, span.span_context());
.json(&request_body) match rb.send().await {
.send() Ok(r) => r,
.await Err(e) => {
.with_context(|| format!("Failed to connect to Ollama at {}", url))?; 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() { if !response.status().is_success() {
let status = response.status(); 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(); let body = response.text().await.unwrap_or_default();
anyhow::bail!( anyhow::bail!(
"Ollama stream request failed with status {}: {}", "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 body
); );
} }
crate::otel::set_http_status(Some(&mut span), response.status().as_u16());
// Ollama streams NDJSON: each line is a full `OllamaStreamChunk`. // Ollama streams NDJSON: each line is a full `OllamaStreamChunk`.
// We buffer partial lines across chunks from the byte stream. // 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_count,
eval_duration, 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 { let message = ChatMessage {
role, role,
content: accumulated, 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( async fn try_chat_with_tools(
&self, &self,
mut span: Option<&mut opentelemetry::global::BoxedSpan>,
base_url: &str, base_url: &str,
messages: Vec<ChatMessage>, messages: Vec<ChatMessage>,
tools: Vec<Tool>, 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)); .unwrap_or_else(|e| format!("<serialization error: {}>", e));
log::debug!("chat_with_tools request body: {}", request_json); log::debug!("chat_with_tools request body: {}", request_json);
let response = self let response = {
.client let mut rb = self.client.post(&url).json(&request_body);
.post(&url) if let Some(s) = span.as_mut() {
.json(&request_body) rb = crate::otel::with_traceparent(rb, s.span_context());
.send() }
.await match rb.send().await {
.with_context(|| format!("Failed to connect to Ollama at {}", url))?; 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() { if !response.status().is_success() {
let status = response.status(); 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(); let body = response.text().await.unwrap_or_default();
// warn, not error — the outer `chat_with_tools` may recover via // warn, not error — the outer `chat_with_tools` may recover via
// the fallback server. When both fail, the outer layer emits the // 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 body
); );
} }
crate::otel::set_http_status(span.as_deref_mut(), response.status().as_u16());
let chat_response: OllamaChatResponse = response let chat_response: OllamaChatResponse = match response.json().await {
.json() Ok(r) => r,
.await Err(e) => {
.with_context(|| "Failed to parse Ollama chat response")?; 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 // Log performance counters returned by Ollama. Durations are
// reported in nanoseconds; we render ms + tokens/sec for skim-ability // 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 /// Generate an embedding vector for text using nomic-embed-text:v1.5
/// Returns a 768-dimensional vector as Vec<f32> /// Returns a 768-dimensional vector as Vec<f32>
pub async fn generate_embedding(&self, text: &str) -> Result<Vec<f32>> { pub async fn generate_embedding(&self, cx: &Context, text: &str) -> Result<Vec<f32>> {
let embeddings = self.generate_embeddings(&[text]).await?; let embeddings = self.generate_embeddings(cx, &[text]).await?;
embeddings embeddings
.into_iter() .into_iter()
.next() .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) /// Generate embeddings for multiple texts in a single API call (batch mode)
/// Returns a vector of 768-dimensional vectors /// Returns a vector of 768-dimensional vectors
/// This is much more efficient than calling generate_embedding multiple times /// 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 embedding_model = EMBEDDING_MODEL;
let mut span = self.start_request_span(cx, "embeddings", embedding_model);
log::debug!("=== Ollama Batch Embedding Request ==="); log::debug!("=== Ollama Batch Embedding Request ===");
log::debug!("Model: {}", embedding_model); log::debug!("Model: {}", embedding_model);
log::debug!("Batch size: {} texts", texts.len()); 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 embedding_model
); );
let primary_result = self 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; .await;
let embeddings = match primary_result { 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 embedding_model
); );
match self match self
.try_generate_embeddings(fallback_url, embedding_model, texts) .try_generate_embeddings(
Some(&mut span),
fallback_url,
embedding_model,
texts,
)
.await .await
{ {
Ok(embeddings) => { 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 /// Internal helper to try generating embeddings for multiple texts from a specific server
async fn try_generate_embeddings( async fn try_generate_embeddings(
&self, &self,
mut span: Option<&mut opentelemetry::global::BoxedSpan>,
url: &str, url: &str,
model: &str, model: &str,
texts: &[&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(), input: texts.iter().map(|s| s.to_string()).collect(),
}; };
let response = self let response = {
.client let mut rb = self
.post(format!("{}/api/embed", url)) .client
.json(&request) .post(format!("{}/api/embed", url))
.send() .json(&request);
.await?; 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() { if !response.status().is_success() {
let status = response.status(); 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(); let error_body = response.text().await.unwrap_or_default();
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Ollama batch embedding request failed: {} - {}", "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 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) 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 { impl LlmClient for OllamaClient {
async fn generate( async fn generate(
&self, &self,
cx: &Context,
prompt: &str, prompt: &str,
system: Option<&str>, system: Option<&str>,
images: Option<Vec<String>>, images: Option<Vec<String>>,
) -> Result<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( async fn chat_with_tools(
&self, &self,
cx: &Context,
messages: Vec<ChatMessage>, messages: Vec<ChatMessage>,
tools: Vec<Tool>, tools: Vec<Tool>,
) -> Result<(ChatMessage, Option<i32>, Option<i32>)> { ) -> 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( async fn chat_with_tools_stream(
&self, &self,
cx: &Context,
messages: Vec<ChatMessage>, messages: Vec<ChatMessage>,
tools: Vec<Tool>, tools: Vec<Tool>,
) -> Result<BoxStream<'static, Result<LlmStreamEvent>>> { ) -> 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>>> { async fn generate_embeddings(&self, cx: &Context, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
OllamaClient::generate_embeddings(self, texts).await OllamaClient::generate_embeddings(self, cx, texts).await
} }
async fn describe_image(&self, image_base64: &str) -> Result<String> { async fn describe_image(&self, cx: &Context, image_base64: &str) -> Result<String> {
self.generate_photo_description(image_base64).await self.generate_photo_description(cx, image_base64).await
} }
async fn list_models(&self) -> Result<Vec<ModelCapabilities>> { async fn list_models(&self) -> Result<Vec<ModelCapabilities>> {
+202 -83
View File
@@ -2,8 +2,11 @@
// the translation helpers directly. // the translation helpers directly.
#![allow(dead_code)] #![allow(dead_code)]
use anyhow::{Context, Result, anyhow, bail}; use anyhow::Context as _;
use anyhow::{Result, anyhow, bail};
use async_trait::async_trait; use async_trait::async_trait;
use opentelemetry::trace::{Span, Status};
use opentelemetry::{Context, KeyValue};
use reqwest::Client; use reqwest::Client;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -14,6 +17,7 @@ use std::time::{Duration, Instant};
use crate::ai::llm_client::{ use crate::ai::llm_client::{
ChatMessage, LlmClient, LlmStreamEvent, ModelCapabilities, Tool, ToolCall, ToolCallFunction, ChatMessage, LlmClient, LlmStreamEvent, ModelCapabilities, Tool, ToolCall, ToolCallFunction,
}; };
use futures::stream::{BoxStream, StreamExt}; use futures::stream::{BoxStream, StreamExt};
const DEFAULT_BASE_URL: &str = "https://openrouter.ai/api/v1"; const DEFAULT_BASE_URL: &str = "https://openrouter.ai/api/v1";
@@ -144,6 +148,19 @@ impl OpenRouterClient {
b 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. /// Translate canonical messages to the OpenAI-compatible wire shape.
/// ///
/// Walks in order so it can attach `tool_call_id` to `role=tool` messages /// Walks in order so it can attach `tool_call_id` to `role=tool` messages
@@ -312,6 +329,7 @@ impl OpenRouterClient {
impl LlmClient for OpenRouterClient { impl LlmClient for OpenRouterClient {
async fn generate( async fn generate(
&self, &self,
cx: &Context,
prompt: &str, prompt: &str,
system: Option<&str>, system: Option<&str>,
images: Option<Vec<String>>, images: Option<Vec<String>>,
@@ -324,90 +342,34 @@ impl LlmClient for OpenRouterClient {
user.images = images; user.images = images;
messages.push(user); 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) Ok(reply.content)
} }
async fn chat_with_tools( async fn chat_with_tools(
&self, &self,
cx: &Context,
messages: Vec<ChatMessage>, messages: Vec<ChatMessage>,
tools: Vec<Tool>, tools: Vec<Tool>,
) -> Result<(ChatMessage, Option<i32>, Option<i32>)> { ) -> Result<(ChatMessage, Option<i32>, Option<i32>)> {
let url = format!("{}/chat/completions", self.base_url); let mut span = self.start_request_span(cx, "chat", &self.primary_model);
let mut body = serde_json::Map::new(); self.chat_with_tools_impl(Some(&mut span), messages, tools)
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()
.await .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( async fn chat_with_tools_stream(
&self, &self,
cx: &Context,
messages: Vec<ChatMessage>, messages: Vec<ChatMessage>,
tools: Vec<Tool>, tools: Vec<Tool>,
) -> Result<BoxStream<'static, Result<LlmStreamEvent>>> { ) -> 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 url = format!("{}/chat/completions", self.base_url);
let mut body = serde_json::Map::new(); let mut body = serde_json::Map::new();
body.insert("model".into(), Value::String(self.primary_model.clone())); body.insert("model".into(), Value::String(self.primary_model.clone()));
@@ -432,18 +394,26 @@ impl LlmClient for OpenRouterClient {
body.insert(k.into(), v); body.insert(k.into(), v);
} }
let resp = self let mut request = self
.authed(self.client.post(&url)) .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() .send()
.await .await
.with_context(|| format!("POST {} failed", url))?; .with_context(|| format!("POST {} failed", url))?;
if !resp.status().is_success() { if !resp.status().is_success() {
let status = resp.status(); 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(); let body = resp.text().await.unwrap_or_default();
bail!("OpenRouter stream request failed: {} — {}", status, body); 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 // OpenAI-compat SSE stream. Each event is `data: <json>\n\n`, with
// `data: [DONE]` signalling completion. Tool calls arrive as // `data: [DONE]` signalling completion. Tool calls arrive as
@@ -467,6 +437,7 @@ impl LlmClient for OpenRouterClient {
let chunk = match chunk { let chunk = match chunk {
Ok(b) => b, Ok(b) => b,
Err(e) => { Err(e) => {
span.set_status(Status::error(format!("stream read failed: {e}")));
yield Err(anyhow!("stream read failed: {}", e)); yield Err(anyhow!("stream read failed: {}", e));
return; return;
} }
@@ -517,6 +488,9 @@ impl LlmClient for OpenRouterClient {
.get("completion_tokens") .get("completion_tokens")
.and_then(|n| n.as_i64()) .and_then(|n| n.as_i64())
.map(|n| n as i32); .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()) let Some(choices) = v.get("choices").and_then(|c| c.as_array())
@@ -606,6 +580,13 @@ impl LlmClient for OpenRouterClient {
tool_calls, tool_calls,
images: None, 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 { yield Ok(LlmStreamEvent::Done {
message, message,
prompt_eval_count: prompt_tokens, prompt_eval_count: prompt_tokens,
@@ -616,25 +597,35 @@ impl LlmClient for OpenRouterClient {
Ok(Box::pin(stream)) 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 url = format!("{}/embeddings", self.base_url);
let body = json!({ let body = json!({
"model": self.embedding_model, "model": self.embedding_model,
"input": texts, "input": texts,
}); });
let resp = self let mut request = self.authed(self.client.post(&url)).json(&body);
.authed(self.client.post(&url)) request = crate::otel::with_traceparent(request, span.span_context());
.json(&body) let resp = request
.send() .send()
.await .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() { if !resp.status().is_success() {
let status = resp.status(); 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(); let body = resp.text().await.unwrap_or_default();
bail!("OpenRouter embedding request failed: {} — {}", status, body); bail!("OpenRouter embedding request failed: {} — {}", status, body);
} }
crate::otel::set_http_status(Some(&mut span), resp.status().as_u16());
#[derive(Deserialize)] #[derive(Deserialize)]
struct EmbedResponse { struct EmbedResponse {
@@ -646,18 +637,28 @@ impl LlmClient for OpenRouterClient {
} }
let parsed: EmbedResponse = resp.json().await.context("parsing embed response")?; 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()) 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. \ let prompt = "Briefly describe what you see in this image in 1-2 sentences. \
Focus on the people, location, and activity."; Focus on the people, location, and activity.";
self.generate( let mut messages: Vec<ChatMessage> = Vec::new();
prompt, messages.push(ChatMessage::system(
Some("You are a scene description assistant. Be concise and factual."), "You are a scene description assistant. Be concise and factual.",
Some(vec![image_base64.to_string()]), ));
) let mut user = ChatMessage::user(prompt);
.await 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>> { 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 /// Extract a diagnostic fragment from an OpenRouter response body that
/// doesn't match the expected `{choices: [...]}` shape. OpenRouter will /// doesn't match the expected `{choices: [...]}` shape. OpenRouter will
/// sometimes return 200 OK with `{"error": {"message": "...", "code": ...}}` /// 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 anyhow::{Context, Result, anyhow, bail};
use chrono::Utc; use chrono::Utc;
use opentelemetry::KeyValue; use opentelemetry::KeyValue;
use opentelemetry::trace::{Span, Status, Tracer}; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex as StdMutex}; use std::sync::{Arc, Mutex as StdMutex};
@@ -685,6 +685,7 @@ impl PersonaChatSession {
entry_clone.clone(), entry_clone.clone(),
user_id, user_id,
library_id, library_id,
&mut span,
) )
.await; .await;
match result { match result {
@@ -721,6 +722,7 @@ impl PersonaChatSession {
entry: Arc<TurnEntry>, entry: Arc<TurnEntry>,
user_id: i32, user_id: i32,
_library_id: i32, _library_id: i32,
turn_span: &mut opentelemetry::global::BoxedSpan,
) -> Result<()> { ) -> Result<()> {
// Per-conversation mutex — serializes this turn against a // Per-conversation mutex — serializes this turn against a
// concurrent rewind or branch switch on the same transcript. // concurrent rewind or branch switch on the same transcript.
@@ -808,6 +810,12 @@ impl PersonaChatSession {
.resolve_backend(backend_kind, &overrides) .resolve_backend(backend_kind, &overrides)
.await?; .await?;
let model_used = backend.model().to_string(); 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` // Build a no-photo tool catalog. `current_gate_opts_for_persona`
// already probes each tool's backing table for presence and // already probes each tool's backing table for presence and
@@ -874,6 +882,7 @@ impl PersonaChatSession {
&persona.persona_id, &persona.persona_id,
max_iterations, max_iterations,
&entry, &entry,
&turn_cx,
) )
.await .await
.map_err(|e| anyhow!("persona chat loop failed: {e:?}"))?; .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. // list falls back to the persona name while the title is empty.
if needs_title { if needs_title {
let title = self let title = self
.generate_title(&backend, &messages) .generate_title(&backend, &messages, &turn_cx)
.await .await
.unwrap_or_else(|| fallback_title(&messages)); .unwrap_or_else(|| fallback_title(&messages));
if !title.is_empty() { if !title.is_empty() {
@@ -1047,6 +1056,7 @@ impl PersonaChatSession {
&self, &self,
backend: &crate::ai::backend::ResolvedBackend, backend: &crate::ai::backend::ResolvedBackend,
messages: &[ChatMessage], messages: &[ChatMessage],
cx: &opentelemetry::Context,
) -> Option<String> { ) -> Option<String> {
let user_message = messages.iter().find(|m| m.role == "user")?; let user_message = messages.iter().find(|m| m.role == "user")?;
// The last assistant message with actual prose — earlier ones may be // 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())?; .find(|m| m.role == "assistant" && !m.content.trim().is_empty())?;
let prompt = title_prompt(&user_message.content, &reply.content); 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) => { Ok(raw) => {
let title = sanitize_title(&crate::ai::llm_client::strip_think_blocks(&raw)); let title = sanitize_title(&crate::ai::llm_client::strip_think_blocks(&raw));
if title.is_empty() { None } else { Some(title) } if title.is_empty() { None } else { Some(title) }
+2
View File
@@ -324,6 +324,7 @@ impl SmsApiClient {
&self, &self,
messages: &[SmsMessage], messages: &[SmsMessage],
ollama: &OllamaClient, ollama: &OllamaClient,
cx: &opentelemetry::Context,
) -> Result<String> { ) -> Result<String> {
if messages.is_empty() { if messages.is_empty() {
return Ok(String::from("No messages on this day")); return Ok(String::from("No messages on this day"));
@@ -356,6 +357,7 @@ Summary:"#,
ollama ollama
.generate( .generate(
cx,
&prompt, &prompt,
// Some("You are a summarizer for the purposes of jogging my memory and highlighting events and situations."), // 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."), Some("You are the keeper of memories, ingest the context and give me a casual summary of the moment."),
+4
View File
@@ -185,6 +185,9 @@ async fn main() -> anyhow::Result<()> {
Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new()))); Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new())));
let face_dao: Arc<Mutex<Box<dyn FaceDao>>> = let face_dao: Arc<Mutex<Box<dyn FaceDao>>> =
Arc::new(Mutex::new(Box::new(SqliteFaceDao::new()))); 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( let persona_dao: Arc<Mutex<Box<dyn image_api::database::PersonaDao>>> = Arc::new(Mutex::new(
Box::new(image_api::database::SqlitePersonaDao::new()), Box::new(image_api::database::SqlitePersonaDao::new()),
)); ));
@@ -208,6 +211,7 @@ async fn main() -> anyhow::Result<()> {
face_dao, face_dao,
knowledge_dao, knowledge_dao,
persona_dao, persona_dao,
user_ai_prefs_dao,
all_libs.clone(), all_libs.clone(),
); );
+6 -2
View File
@@ -220,7 +220,9 @@ async fn main() -> Result<()> {
println!("Generating summary..."); 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!("\n📝 GENERATED SUMMARY:");
println!("─────────────────────────────────────────"); println!("─────────────────────────────────────────");
@@ -236,7 +238,9 @@ async fn main() -> Result<()> {
); );
println!("\nGenerating embedding..."); 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!("✓ Embedding generated ({} dimensions)", embedding.len());
println!("Saving to database..."); println!("Saving to database...");
+44 -1
View File
@@ -170,7 +170,16 @@ pub fn resolve_camera_offset(
} }
let minutes = match server_zone { let minutes = match server_zone {
Some(zone) => offset_minutes_for_zone(zone, wall), 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 { OffsetResolution {
minutes, minutes,
@@ -338,6 +347,40 @@ mod tests {
assert_eq!(res.source, TzSource::AssumedServer); 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] #[test]
fn gps_miss_falls_through_to_server_zone() { fn gps_miss_falls_through_to_server_zone() {
let evidence = OffsetEvidence { let evidence = OffsetEvidence {
+8
View File
@@ -428,7 +428,15 @@ pub struct GpsPhotoSummary {
pub path: String, pub path: String,
pub lat: f64, pub lat: f64,
pub lon: f64, pub lon: f64,
/// Legacy mixed-convention date (frozen for existing rows; ADR-0001).
pub date_taken: Option<i64>, 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)] #[derive(Debug, Serialize)]
+179 -124
View File
@@ -9,21 +9,6 @@ use crate::database::models::{
}; };
use crate::otel::trace_db_call; 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 /// Wire shape for a single member of a duplicate group, returned by
/// `list_duplicates_*` and `lookup_duplicate_row`. Carries everything /// `list_duplicates_*` and `lookup_duplicate_row`. Carries everything
/// the Apollo modal needs to render a member tile and its meta line — /// 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, context: &opentelemetry::Context,
base_path: &str, base_path: &str,
recursive: bool, 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 /// Return rows that still lack a `content_hash`, oldest first. Used by
/// the `backfill_hashes` binary to batch through the historical /// the `backfill_hashes` binary to batch through the historical
@@ -652,34 +648,34 @@ pub trait ExifDao: Sync + Send {
rel_path: &str, rel_path: &str,
) -> Result<ImageExif, DbError>; ) -> Result<ImageExif, DbError>;
/// Single-query backend for `/memories`. Returns /// Coarse pre-filter for the `/memories` span query (ADR-0001).
/// `(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)
/// ///
/// `tz_offset_minutes` is applied to both sides of the strftime /// The span feature matches the same calendar position (month-day /
/// comparison so the calendar match is in the user's local time. /// week-of-year / month) across every past year, so this query only
/// Backed by the `(library_id, date_taken)` index. /// 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 + /// Eligibility:
/// WalkDir-fallback that powered `/memories` previously; it's /// - `date_taken` must be set and sit inside the unconverted band
/// correct only because the canonical-date waterfall at ingest /// `[unconv_lo, unconv_hi]` (a ±15 h margin around the years-back
/// (`crate::date_resolver`) populates `date_taken` for every row /// window that covers every per-source legacy convention, and which
/// it can resolve. /// 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( fn get_memories_in_window(
&mut self, &mut self,
context: &opentelemetry::Context, context: &opentelemetry::Context,
library_id: i32, library_id: i32,
span_token: &str, conv_lo: i64,
years_back: i32, conv_hi: i64,
tz_offset_minutes: i32, unconv_lo: i64,
) -> Result<Vec<(String, i64, i64)>, DbError>; unconv_hi: i64,
) -> Result<Vec<ImageExif>, DbError>;
/// Return image rows that have a `content_hash` but no `phash_64`, /// Return image rows that have a `content_hash` but no `phash_64`,
/// oldest first. Used by the `backfill_perceptual_hash` binary. /// 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)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
#[allow(clippy::type_complexity)]
fn get_all_with_gps( fn get_all_with_gps(
&mut self, &mut self,
context: &opentelemetry::Context, context: &opentelemetry::Context,
base_path: &str, base_path: &str,
recursive: bool, 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| { trace_db_call(context, "query", "get_all_with_gps", |span| {
use opentelemetry::KeyValue; use opentelemetry::KeyValue;
use opentelemetry::trace::Span; use opentelemetry::trace::Span;
@@ -1334,10 +1342,19 @@ impl ExifDao for SqliteExifDao {
.load::<ImageExif>(connection) .load::<ImageExif>(connection)
.map_err(|e| anyhow::anyhow!("GPS query error: {}", e))?; .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) // Filter out any rows where GPS is still None (shouldn't happen due to filter)
// Cast f32 GPS values to f64 for API compatibility // 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() .into_iter()
.filter_map(|exif| { .filter_map(|exif| {
if let (Some(lat_val), Some(lon_val)) = (exif.gps_latitude, exif.gps_longitude) 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, lat_val as f64,
lon_val as f64, lon_val as f64,
exif.date_taken, exif.date_taken,
exif.date_utc,
exif.camera_offset_minutes,
exif.date_taken_tz_source,
)) ))
} else { } else {
None None
@@ -1937,51 +1957,27 @@ impl ExifDao for SqliteExifDao {
&mut self, &mut self,
context: &opentelemetry::Context, context: &opentelemetry::Context,
library_id: i32, library_id: i32,
span_token: &str, conv_lo: i64,
years_back: i32, conv_hi: i64,
tz_offset_minutes: i32, unconv_lo: i64,
) -> Result<Vec<(String, i64, i64)>, DbError> { unconv_hi: i64,
) -> Result<Vec<ImageExif>, DbError> {
trace_db_call(context, "query", "get_memories_in_window", |_span| { trace_db_call(context, "query", "get_memories_in_window", |_span| {
// strftime pattern is span-dependent; the rest of the WHERE use crate::database::schema::image_exif::dsl as d;
// 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,
);
let mut connection = self.connection.lock().expect("Unable to get ExifDao"); let mut connection = self.connection.lock().expect("Unable to get ExifDao");
diesel::sql_query(sql) d::image_exif
.bind::<diesel::sql_types::Integer, _>(library_id) .filter(d::library_id.eq(library_id))
.bind::<diesel::sql_types::Text, _>(years_modifier) .filter(d::date_taken.is_not_null())
.bind::<diesel::sql_types::Text, _>(tz_modifier) .filter(d::date_taken.ge(unconv_lo))
.load::<MemoriesWindowRow>(connection.deref_mut()) .filter(d::date_taken.le(unconv_hi))
.map(|rows| { .filter(
rows.into_iter() d::date_utc
.map(|r| (r.rel_path, r.date_taken, r.last_modified)) .is_null()
.collect() .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| anyhow::anyhow!("Query error: {}", e))
}) })
.map_err(|e| DbError::log(DbErrorKind::QueryError, e)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
@@ -2966,25 +2962,21 @@ mod exif_dao_tests {
assert_eq!(row.size_bytes, Some(1024)); 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] #[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(); 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 now_ts = chrono::Utc::now().timestamp();
let year_secs: i64 = 365 * 86_400; let year_secs: i64 = 365 * 86_400;
insert_row_with_source( insert_row_with_source(
@@ -2994,13 +2986,6 @@ mod exif_dao_tests {
Some(now_ts - year_secs), Some(now_ts - year_secs),
Some("exif"), Some("exif"),
); );
insert_row_with_source(
&mut dao,
1,
"y5.jpg",
Some(now_ts - 5 * year_secs),
Some("exif"),
);
insert_row_with_source( insert_row_with_source(
&mut dao, &mut dao,
1, 1,
@@ -3027,20 +3012,15 @@ mod exif_dao_tests {
// No date — never returned regardless of source. // No date — never returned regardless of source.
insert_row_with_source(&mut dao, 1, "nodate.jpg", None, None); insert_row_with_source(&mut dao, 1, "nodate.jpg", None, None);
// Month span returns rows from the same calendar month over the let (clo, chi, ulo, uhi) = wide_windows(now_ts, 20);
// 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 rows = dao let rows = dao
.get_memories_in_window(&ctx(), 1, "month", 20, 0) .get_memories_in_window(&ctx(), 1, clo, chi, ulo, uhi)
.unwrap(); .unwrap();
let paths: std::collections::HashSet<String> = let paths: std::collections::HashSet<String> =
rows.into_iter().map(|(p, _, _)| p).collect(); rows.into_iter().map(|r| r.file_path).collect();
assert!( assert!(
paths.contains("y1.jpg") && paths.contains("y5.jpg") && paths.contains("y10.jpg"), paths.contains("y1.jpg") && paths.contains("y10.jpg"),
"month span should include all in-window rows: {:?}", "in-window rows should be returned: {:?}",
paths paths
); );
assert!( assert!(
@@ -3067,23 +3047,98 @@ mod exif_dao_tests {
Some(now_ts - year), Some(now_ts - year),
Some("exif"), Some("exif"),
); );
let (clo, chi, ulo, uhi) = wide_windows(now_ts, 20);
let lib1 = dao let lib1 = dao
.get_memories_in_window(&ctx(), 1, "month", 20, 0) .get_memories_in_window(&ctx(), 1, clo, chi, ulo, uhi)
.unwrap(); .unwrap();
let lib2 = dao let lib2 = dao
.get_memories_in_window(&ctx(), 2, "month", 20, 0) .get_memories_in_window(&ctx(), 2, clo, chi, ulo, uhi)
.unwrap(); .unwrap();
assert_eq!(lib1.len(), 1); 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.len(), 1);
assert_eq!(lib2[0].0, "archive/x.jpg"); assert_eq!(lib2[0].file_path, "archive/x.jpg");
} }
#[test] #[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 mut dao = setup_two_libraries();
let err = dao.get_memories_in_window(&ctx(), 1, "decade", 20, 0); let now_ts = chrono::Utc::now().timestamp();
assert!(err.is_err()); 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 /// default (`camera_tz::DEFAULT_DISPLAY_TZ`). The legacy
/// `tz_offset_minutes` column stays frozen for rollback. /// `tz_offset_minutes` column stays frozen for rollback.
pub display_tz: Option<String>, 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)] #[derive(Insertable, Debug, Clone, serde::Deserialize, serde::Serialize)]
@@ -589,4 +602,9 @@ pub struct UpsertUserAiPrefs {
pub library: Option<String>, pub library: Option<String>,
pub updated_at: i64, pub updated_at: i64,
pub display_tz: Option<String>, 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>, library -> Nullable<Text>,
updated_at -> BigInt, updated_at -> BigInt,
display_tz -> Nullable<Text>, 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::tz_offset_minutes.eq(&prefs.tz_offset_minutes),
dsl::library.eq(&prefs.library), dsl::library.eq(&prefs.library),
dsl::display_tz.eq(&prefs.display_tz), 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), dsl::updated_at.eq(&prefs.updated_at),
)) ))
.execute(connection.deref_mut()) .execute(connection.deref_mut())
@@ -147,6 +152,11 @@ mod tests {
library: Some("1".to_string()), library: Some("1".to_string()),
updated_at: now, updated_at: now,
display_tz: Some("America/New_York".to_string()), 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(); dao.upsert_prefs(&ctx(), &prefs).unwrap();
@@ -156,6 +166,11 @@ mod tests {
assert_eq!(row.tz_offset_minutes, Some(-480)); assert_eq!(row.tz_offset_minutes, Some(-480));
assert_eq!(row.library, Some("1".to_string())); assert_eq!(row.library, Some("1".to_string()));
assert_eq!(row.display_tz, Some("America/New_York".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); assert_eq!(row.updated_at, now);
} }
@@ -171,6 +186,11 @@ mod tests {
library: Some("1".to_string()), library: Some("1".to_string()),
updated_at: now1, updated_at: now1,
display_tz: Some("America/New_York".to_string()), 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(); dao.upsert_prefs(&ctx(), &prefs1).unwrap();
@@ -180,6 +200,11 @@ mod tests {
library: None, library: None,
updated_at: now2, updated_at: now2,
display_tz: None, 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(); dao.upsert_prefs(&ctx(), &prefs2).unwrap();
@@ -188,6 +213,11 @@ mod tests {
assert_eq!(row.tz_offset_minutes, Some(-300)); assert_eq!(row.tz_offset_minutes, Some(-300));
assert!(row.library.is_none()); assert!(row.library.is_none());
assert!(row.display_tz.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); assert_eq!(row.updated_at, now2);
} }
@@ -202,6 +232,11 @@ mod tests {
library: None, library: None,
updated_at: now, updated_at: now,
display_tz: Some("Europe/Berlin".to_string()), 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(); dao.upsert_prefs(&ctx(), &prefs).unwrap();
@@ -210,5 +245,9 @@ mod tests {
assert!(row.voice.is_none()); assert!(row.voice.is_none());
assert!(row.library.is_none()); assert!(row.library.is_none());
assert_eq!(row.display_tz, Some("Europe/Berlin".to_string())); 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));
}
}
+36 -11
View File
@@ -1142,12 +1142,25 @@ pub async fn get_gps_summary(
Ok(gps_data) => { Ok(gps_data) => {
let mut photos: Vec<GpsPhotoSummary> = gps_data let mut photos: Vec<GpsPhotoSummary> = gps_data
.into_iter() .into_iter()
.map(|(path, lat, lon, date_taken)| GpsPhotoSummary { .map(
path, |(
lat, path,
lon, lat,
date_taken, 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(); .collect();
// Sort by date_taken based on request, defaulting to ascending (oldest to newest) // Sort by date_taken based on request, defaulting to ascending (oldest to newest)
@@ -1695,7 +1708,18 @@ mod tests {
_context: &opentelemetry::Context, _context: &opentelemetry::Context,
_base_path: &str, _base_path: &str,
_recursive: bool, _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!() todo!()
} }
@@ -1817,10 +1841,11 @@ mod tests {
&mut self, &mut self,
_context: &opentelemetry::Context, _context: &opentelemetry::Context,
_library_id: i32, _library_id: i32,
_span_token: &str, _conv_lo: i64,
_years_back: i32, _conv_hi: i64,
_tz_offset_minutes: i32, _unconv_lo: i64,
) -> Result<Vec<(String, i64, i64)>, DbError> { _unconv_hi: i64,
) -> Result<Vec<crate::database::models::ImageExif>, DbError> {
Ok(Vec::new()) 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 ollama = app_state.ollama.clone();
let model_used = ollama.primary_model.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) => { Ok(out) => {
// Strip the framing models reach for even with explicit // Strip the framing models reach for even with explicit
// "no preamble" guidance: leading "Merged description:" // "no preamble" guidance: leading "Merged description:"
+2
View File
@@ -14,6 +14,7 @@ pub mod content_hash;
pub mod data; pub mod data;
pub mod database; pub mod database;
pub mod date_resolver; pub mod date_resolver;
pub mod date_view;
pub mod duplicates; pub mod duplicates;
pub mod error; pub mod error;
pub mod exif; pub mod exif;
@@ -31,6 +32,7 @@ pub mod otel;
pub mod parsers; pub mod parsers;
pub mod perceptual_hash; pub mod perceptual_hash;
pub mod personas; pub mod personas;
pub mod prefs;
pub mod service; pub mod service;
pub mod state; pub mod state;
pub mod tags; pub mod tags;
+8
View File
@@ -41,6 +41,7 @@ mod content_hash;
mod data; mod data;
mod database; mod database;
mod date_resolver; mod date_resolver;
mod date_view;
mod duplicates; mod duplicates;
mod error; mod error;
mod exif; mod exif;
@@ -67,6 +68,7 @@ mod knowledge;
mod memories; mod memories;
mod otel; mod otel;
mod personas; mod personas;
mod prefs;
mod reels; mod reels;
mod service; mod service;
#[cfg(test)] #[cfg(test)]
@@ -373,6 +375,12 @@ fn main() -> std::io::Result<()> {
.service(reels::reel_video_handler) .service(reels::reel_video_handler)
.service(reels::precomputed_reel_handler) .service(reels::precomputed_reel_handler)
.service(reels::precomputed_video_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_insight_handler)
.service(ai::generate_agentic_insight_handler) .service(ai::generate_agentic_insight_handler)
.service(ai::generation_status_handler) .service(ai::generation_status_handler)
+477 -47
View File
@@ -1,12 +1,13 @@
use actix_web::web::Data; use actix_web::web::Data;
use actix_web::{HttpRequest, HttpResponse, Responder, get, web}; 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 log::{debug, trace, warn};
use opentelemetry::KeyValue; use opentelemetry::KeyValue;
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::Path; use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Mutex; use std::sync::Mutex;
use crate::data::Claims; use crate::data::Claims;
@@ -107,10 +108,38 @@ pub enum MemoriesSpan {
Month, 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)] #[derive(Deserialize)]
pub struct MemoriesRequest { pub struct MemoriesRequest {
pub span: Option<MemoriesSpan>, 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>, pub timezone_offset_minutes: Option<i32>,
/// Optional library filter. Accepts a library id (e.g. "1") or name /// Optional library filter. Accepts a library id (e.g. "1") or name
/// (e.g. "main"). When omitted, results span all libraries. /// (e.g. "main"). When omitted, results span all libraries.
@@ -120,11 +149,21 @@ pub struct MemoriesRequest {
#[derive(Debug, Serialize, Clone)] #[derive(Debug, Serialize, Clone)]
pub struct MemoryItem { pub struct MemoryItem {
pub path: String, 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 created: Option<i64>,
pub modified: Option<i64>, pub modified: Option<i64>,
/// Id of the library this memory belongs to. Allows clients to show a /// Id of the library this memory belongs to. Allows clients to show a
/// per-item source badge in union mode. /// per-item source badge in union mode.
pub library_id: i32, 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)] #[derive(Debug, Serialize)]
@@ -132,6 +171,50 @@ pub struct MemoriesResponse {
pub items: Vec<MemoryItem>, 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). /// A date carried by a filename, with its convention (ticket #116).
/// ///
/// The two families of pattern make fundamentally different claims: /// The two families of pattern make fundamentally different claims:
@@ -398,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 /// Default lookback for `/memories`. The original 15-year cap pre-dated
/// most of the imported libraries; bumped to 20 so users with deeper /// most of the imported libraries; bumped to 20 so users with deeper
/// archives see those photos surface on the matching anniversary too. /// archives see those photos surface on the matching anniversary too.
@@ -430,23 +502,29 @@ pub async fn list_memories(
let span_mode = q.span.unwrap_or(MemoriesSpan::Day); let span_mode = q.span.unwrap_or(MemoriesSpan::Day);
// The SQL filter expects a signed offset in minutes from UTC; default // Grouping happens in the owner's display zone (prefs → app default),
// 0 (UTC) when the client didn't send a hint. We also keep a chrono // not the client's device offset (ADR-0001): a photo taken in Japan at
// `FixedOffset` for sorting/secondary-key date math in Rust below — // 21:00 JST belongs to the owner's day, and a container's UTC-rendered
// anchoring both sides on the same value keeps "what SQL matched" and // creation time is a true instant. The legacy `timezone_offset_minutes`
// "what we sort by" consistent. // param is still accepted on the wire for old clients but no longer
let tz_offset_minutes = q.timezone_offset_minutes.unwrap_or(0); // drives grouping.
let client_timezone = q let display_zone_name = {
.timezone_offset_minutes let prefs = app_state
.and_then(|offset_mins| FixedOffset::east_opt(offset_mins * 60)); .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( let items = match gather_memory_items(
&app_state, &app_state,
&exif_dao, &exif_dao,
&span_context, &span_context,
span_mode, span_mode,
tz_offset_minutes, display_zone,
client_timezone,
q.library.as_deref(), q.library.as_deref(),
) { ) {
Ok(items) => items, Ok(items) => items,
@@ -462,7 +540,7 @@ pub async fn list_memories(
KeyValue::new("span", format!("{:?}", span_mode)), KeyValue::new("span", format!("{:?}", span_mode)),
KeyValue::new("years_back", DEFAULT_YEARS_BACK.to_string()), KeyValue::new("years_back", DEFAULT_YEARS_BACK.to_string()),
KeyValue::new("result_count", items.len().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)), KeyValue::new("excluded_dirs", format!("{:?}", app_state.excluded_dirs)),
], ],
); );
@@ -474,29 +552,41 @@ pub async fn list_memories(
/// Resolve an "on this day/week/month across past years" window into an /// 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 /// ordered list of [`MemoryItem`]s. Shared by the `/memories` handler and the
/// memory-reel selector so both honour the same library resolution, per-library /// memory-reel selector so both honour the same library resolution, per-library
/// exclusions, timezone handling, and sort order. Returns `Err(message)` only /// exclusions, and sort order. Returns `Err(message)` only when the
/// when the `library` param is invalid (callers map that to 400); per-library /// `library` param is invalid (callers map that to 400); per-library
/// query/lock failures are logged and skipped, matching the handler's /// query/lock failures are logged and skipped, matching the handler's
/// best-effort behaviour. /// 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( pub fn gather_memory_items(
app_state: &AppState, app_state: &AppState,
exif_dao: &Mutex<Box<dyn ExifDao>>, exif_dao: &Mutex<Box<dyn ExifDao>>,
span_context: &opentelemetry::Context, span_context: &opentelemetry::Context,
span_mode: MemoriesSpan, span_mode: MemoriesSpan,
tz_offset_minutes: i32, display_zone: chrono_tz::Tz,
client_timezone: Option<FixedOffset>,
library_param: Option<&str>, library_param: Option<&str>,
) -> Result<Vec<MemoryItem>, String> { ) -> Result<Vec<MemoryItem>, String> {
let span_token = match span_mode { let years_back: i64 = DEFAULT_YEARS_BACK as i64;
MemoriesSpan::Day => "day",
MemoriesSpan::Week => "week", let now = Utc::now();
MemoriesSpan::Month => "month", let now_ts = now.timestamp();
}; let today_d = display_zone
let years_back: i32 = DEFAULT_YEARS_BACK; .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!( debug!(
"gather_memory_items: span={:?} tz_offset_min={} years_back={}", "gather_memory_items: span={:?} display_zone={:?} years_back={}",
span_mode, tz_offset_minutes, years_back span_mode, display_zone, years_back
); );
let library = crate::libraries::resolve_library_param_state(app_state, library_param)?; let library = crate::libraries::resolve_library_param_state(app_state, library_param)?;
@@ -518,9 +608,10 @@ pub fn gather_memory_items(
Ok(mut dao) => match dao.get_memories_in_window( Ok(mut dao) => match dao.get_memories_in_window(
span_context, span_context,
lib.id, lib.id,
span_token, conv_lo,
years_back, conv_hi,
tz_offset_minutes, unconv_lo,
unconv_hi,
) { ) {
Ok(rows) => rows, Ok(rows) => rows,
Err(e) => { Err(e) => {
@@ -537,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 // Apply per-library exclusions in Rust — they're a small
// set and pushing them into the SQL WHERE adds bind-param // set and pushing them into the SQL WHERE adds bind-param
// gymnastics with no measurable win at this scale. // 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) { if path_excluder.is_excluded(&full_path) {
trace!("Memory excluded by PathExcluder: {:?}", full_path); trace!("Memory excluded by PathExcluder: {:?}", full_path);
continue; 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; 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(( memories_with_dates.push((
MemoryItem { MemoryItem {
path: rel_path, path: row.file_path,
created: Some(date_taken_ts), created,
modified: Some(last_modified_ts), modified: Some(row.last_modified),
library_id: lib.id, 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,
)); ));
} }
} }
@@ -1038,6 +1138,9 @@ mod tests {
created: Some(jan_15_2024_9am), created: Some(jan_15_2024_9am),
modified: Some(jan_15_2024_9am), modified: Some(jan_15_2024_9am),
library_id: 1, library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
}, },
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(), NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
), ),
@@ -1047,6 +1150,9 @@ mod tests {
created: Some(jan_15_2020_10am), created: Some(jan_15_2020_10am),
modified: Some(jan_15_2020_10am), modified: Some(jan_15_2020_10am),
library_id: 1, library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
}, },
NaiveDate::from_ymd_opt(2020, 1, 15).unwrap(), NaiveDate::from_ymd_opt(2020, 1, 15).unwrap(),
), ),
@@ -1056,6 +1162,9 @@ mod tests {
created: Some(jan_16_2021_8am), created: Some(jan_16_2021_8am),
modified: Some(jan_16_2021_8am), modified: Some(jan_16_2021_8am),
library_id: 1, library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
}, },
NaiveDate::from_ymd_opt(2021, 1, 16).unwrap(), NaiveDate::from_ymd_opt(2021, 1, 16).unwrap(),
), ),
@@ -1087,4 +1196,325 @@ mod tests {
assert_eq!(memories_with_dates[1].0.created.unwrap(), jan_15_2024_9am); 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); 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 actix_web::http::header::HeaderMap;
use opentelemetry::global::{BoxedSpan, BoxedTracer}; use opentelemetry::global::{BoxedSpan, BoxedTracer};
use opentelemetry::propagation::TextMapPropagator; 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::{Context, KeyValue, global};
use opentelemetry_appender_log::OpenTelemetryLogBridge; use opentelemetry_appender_log::OpenTelemetryLogBridge;
use opentelemetry_otlp::WithExportConfig; 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() 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 { pub fn extract_context_from_request(req: &HttpRequest) -> Context {
let propagator = TraceContextPropagator::new(); let propagator = TraceContextPropagator::new();
propagator.extract(&HeaderExtractor(req.headers())) 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>( pub fn trace_db_call<F, O>(
context: &Context, context: &Context,
query_type: &str, query_type: &str,
@@ -110,3 +179,59 @@ where
result 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::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::{LazyLock, Mutex, Mutex as StdMutex}; use std::sync::{LazyLock, Mutex, Mutex as StdMutex};
use std::time::{Duration, Instant}; 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` /// 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 /// so the nightly pre-gen scheduler can pick them up. Never fails the
/// caller regardless of DB errors. /// caller regardless of DB errors.
fn capture_prefs( fn capture_prefs(
app_state: &AppState, app_state: &AppState,
req: &web::Json<CreateReelRequest>, req: &CreateReelRequest,
library_param: Option<&str>, library_param: Option<&str>,
effective_span: MemoriesSpan,
effective_max_segments: usize,
) -> Result<(), anyhow::Error> { ) -> Result<(), anyhow::Error> {
use crate::database::models::UpsertUserAiPrefs; use crate::database::models::UpsertUserAiPrefs;
let now = std::time::SystemTime::now() 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 mut dao = app_state.user_ai_prefs_dao.lock().expect("lock");
let ctx = opentelemetry::Context::new(); 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( dao.upsert_prefs(
&ctx, &ctx,
&UpsertUserAiPrefs { &UpsertUserAiPrefs {
@@ -104,6 +145,11 @@ fn capture_prefs(
library: Some(library), library: Some(library),
updated_at: now as i64, updated_at: now as i64,
display_tz: req.display_tz.clone().filter(|s| !s.is_empty()), 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}")) .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); // Owner defaults: explicit request param → stored pref → hard default.
let max_segments = req.max_segments.unwrap_or(selector::DEFAULT_MAX_SEGMENTS); // 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 { let selector = ReelSelector::Memories {
span, span,
tz_offset_minutes: req.timezone_offset_minutes.unwrap_or(0), display_tz: display_zone_name,
library: req.library.clone(), library: req.library.clone(),
max_segments, max_segments,
}; };
@@ -475,7 +536,7 @@ pub async fn create_reel_handler(
}, },
); );
// Capture params for passive prefs mirror (best-effort, never fails). // 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 { return HttpResponse::Accepted().json(ReelJobCreatedResponse {
job_id: job_id.to_string(), job_id: job_id.to_string(),
status: ReelJobStatus::Done, status: ReelJobStatus::Done,
@@ -535,7 +596,7 @@ pub async fn create_reel_handler(
with_job(job_id, |job| job.abort = Some(handle.abort_handle())); with_job(job_id, |job| job.abort = Some(handle.abort_handle()));
// Capture params for passive prefs mirror (best-effort, never fails). // 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 { HttpResponse::Accepted().json(ReelJobCreatedResponse {
job_id: job_id.to_string(), 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 /// Load pre-gen parameters: tries the user_ai_prefs DB row first, falls back
/// to env vars, then to server-local defaults. /// to env vars, then to the app default display zone. Returns the display
fn load_pregen_params(app_state: &AppState) -> (i32, Option<String>, String) { /// 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 // Try DB row first
if let Ok(mut dao) = app_state.user_ai_prefs_dao.lock() { if let Ok(mut dao) = app_state.user_ai_prefs_dao.lock() {
let ctx = opentelemetry::Context::new(); let ctx = opentelemetry::Context::new();
if let Ok(Some(prefs)) = dao.get_prefs(&ctx) { 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 voice = prefs.voice;
let library = prefs.library.unwrap_or_else(|| "all".to_string()); let library = prefs.library.unwrap_or_else(|| "all".to_string());
return (tz, voice, library); return (tz, voice, library);
} }
} }
// Fall back to env (explicit offset overrides auto-detect) // Fall back to env (explicit zone overrides auto-detect)
let tz = std::env::var("REEL_PREGEN_TZ_OFFSET_MINUTES") let tz = std::env::var("REEL_PREGEN_DISPLAY_TZ")
.ok() .ok()
.and_then(|v| v.parse().ok()) .filter(|v| !v.trim().is_empty())
.unwrap_or_else(fixed_tz_offset); .unwrap_or_else(|| crate::camera_tz::display_zone_for(None));
let voice = std::env::var("REEL_PREGEN_VOICE").ok(); let voice = std::env::var("REEL_PREGEN_VOICE").ok();
let library = std::env::var("REEL_PREGEN_LIBRARY") let library = std::env::var("REEL_PREGEN_LIBRARY")
.ok() .ok()
@@ -989,17 +1053,6 @@ fn load_pregen_params(app_state: &AppState) -> (i32, Option<String>, String) {
(tz, voice, library) (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`. /// Spawn the nightly pre-generation scheduler. Runs behind `REEL_PREGEN_ENABLED`.
pub(crate) async fn spawn_pregen_scheduler(app_state: web::Data<AppState>) { pub(crate) async fn spawn_pregen_scheduler(app_state: web::Data<AppState>) {
if std::env::var("REEL_PREGEN_ENABLED").ok() != Some("1".to_string()) { 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); let (tz, voice, library) = load_pregen_params(app_state);
for span in spans { 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); 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( async fn pregen_one(
app_state: &AppState, app_state: &AppState,
span: &str, span: &str,
tz: i32, display_tz: &str,
voice: Option<String>, voice: Option<String>,
library: &str, library: &str,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
@@ -1069,7 +1122,7 @@ async fn pregen_one(
let selector = ReelSelector::Memories { let selector = ReelSelector::Memories {
span: memories_span, span: memories_span,
tz_offset_minutes: tz, display_tz: display_tz.to_string(),
library: if library == "all" { library: if library == "all" {
None None
} else { } else {
@@ -1179,7 +1232,8 @@ async fn pregen_one(
title, title,
media_count, media_count,
render_version: RENDER_VERSION as i32, 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(), voice: voice.clone(),
generated_at: now, generated_at: now,
}, },
@@ -1326,9 +1380,121 @@ fn sweep_reel_cache(app_state: &AppState) -> usize {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::database::models::UserAiPrefs;
use crate::libraries::Library; use crate::libraries::Library;
use chrono::TimeZone; 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 { fn photo(p: &str, lib: i32) -> SegmentMedia {
SegmentMedia::Photo { SegmentMedia::Photo {
rel_path: p.to_string(), rel_path: p.to_string(),
@@ -1346,7 +1512,7 @@ mod tests {
fn day_selector() -> ReelSelector { fn day_selector() -> ReelSelector {
ReelSelector::Memories { ReelSelector::Memories {
span: MemoriesSpan::Day, span: MemoriesSpan::Day,
tz_offset_minutes: 0, display_tz: "America/New_York".to_string(),
library: None, library: None,
max_segments: 24, max_segments: 24,
} }
@@ -1379,7 +1545,7 @@ mod tests {
// Span matters. // Span matters.
let week = ReelSelector::Memories { let week = ReelSelector::Memories {
span: MemoriesSpan::Week, span: MemoriesSpan::Week,
tz_offset_minutes: 0, display_tz: "America/New_York".to_string(),
library: None, library: None,
max_segments: 24, max_segments: 24,
}; };
+2 -1
View File
@@ -281,8 +281,9 @@ pub async fn generate_script(
beats: &[PlannedBeat], beats: &[PlannedBeat],
) -> Result<ReelScript> { ) -> Result<ReelScript> {
let (system, user) = build_script_messages(meta, beats); let (system, user) = build_script_messages(meta, beats);
let cx = opentelemetry::Context::current();
let raw = client let raw = client
.generate(&user, Some(&system), None) .generate(&cx, &user, Some(&system), None)
.await .await
.context("LLM script generation failed")?; .context("LLM script generation failed")?;
Ok(parse_script_response(&raw, beats.len())) Ok(parse_script_response(&raw, beats.len()))
+34 -18
View File
@@ -9,9 +9,10 @@
//! runs in the background job. //! runs in the background job.
use std::path::Path; use std::path::Path;
use std::str::FromStr;
use std::sync::Mutex; use std::sync::Mutex;
use chrono::{DateTime, Datelike, FixedOffset}; use chrono::{Datelike, TimeZone};
use super::{PlannedBeat, ReelMeta, SegmentMedia}; use super::{PlannedBeat, ReelMeta, SegmentMedia};
use crate::database::{ExifDao, InsightDao}; use crate::database::{ExifDao, InsightDao};
@@ -64,7 +65,10 @@ pub fn budget_segments(requested_max: usize) -> usize {
pub enum ReelSelector { pub enum ReelSelector {
Memories { Memories {
span: MemoriesSpan, 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>, library: Option<String>,
max_segments: usize, max_segments: usize,
}, },
@@ -78,13 +82,13 @@ impl ReelSelector {
match self { match self {
ReelSelector::Memories { ReelSelector::Memories {
span, span,
tz_offset_minutes, display_tz,
library, library,
max_segments, max_segments,
} => format!( } => format!(
"memories:span={:?}:tz={}:lib={}:max={}", "memories:span={:?}:tz={}:lib={}:max={}",
span, span,
tz_offset_minutes, display_tz,
library.as_deref().unwrap_or("all"), library.as_deref().unwrap_or("all"),
max_segments max_segments
), ),
@@ -281,24 +285,24 @@ pub fn resolve(
match selector { match selector {
ReelSelector::Memories { ReelSelector::Memories {
span, span,
tz_offset_minutes, display_tz,
library, library,
max_segments, 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( let items = memories::gather_memory_items(
app_state, app_state,
exif_dao, exif_dao,
span_context, span_context,
*span, *span,
*tz_offset_minutes, zone,
client_tz,
library.as_deref(), library.as_deref(),
)?; )?;
// Split into photos and video clips; anything that's neither is // Split into photos and video clips; anything that's neither is
// dropped. Years span both, computed before the budget narrows it. // 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 meta = ReelMeta { span: *span, years };
let (photos, videos): (Vec<_>, Vec<_>) = items 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 /// 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. /// 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 let mut years: Vec<i32> = items
.iter() .iter()
.filter_map(|it| it.created) .filter_map(|it| it.created)
.filter_map(|ts| DateTime::from_timestamp(ts, 0)) .filter_map(|ts| zone.timestamp_opt(ts, 0).single())
.map(|dt| match tz { .map(|dt| dt.year())
Some(off) => dt.with_timezone(&off).year(),
None => dt.year(),
})
.collect(); .collect();
years.sort_unstable(); years.sort_unstable();
years.dedup(); years.dedup();
@@ -406,13 +407,13 @@ mod tests {
fn descriptor_is_stable_and_distinguishes_inputs() { fn descriptor_is_stable_and_distinguishes_inputs() {
let a = ReelSelector::Memories { let a = ReelSelector::Memories {
span: MemoriesSpan::Day, span: MemoriesSpan::Day,
tz_offset_minutes: -480, display_tz: "America/New_York".to_string(),
library: None, library: None,
max_segments: 24, max_segments: 24,
}; };
let b = ReelSelector::Memories { let b = ReelSelector::Memories {
span: MemoriesSpan::Week, span: MemoriesSpan::Week,
tz_offset_minutes: -480, display_tz: "America/New_York".to_string(),
library: None, library: None,
max_segments: 24, max_segments: 24,
}; };
@@ -429,21 +430,33 @@ mod tests {
created: Some(1_560_000_000), // 2019 created: Some(1_560_000_000), // 2019
modified: None, modified: None,
library_id: 1, library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
}, },
memories::MemoryItem { memories::MemoryItem {
path: "b".into(), path: "b".into(),
created: Some(1_560_086_400), // 2019 created: Some(1_560_086_400), // 2019
modified: None, modified: None,
library_id: 1, library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
}, },
memories::MemoryItem { memories::MemoryItem {
path: "c".into(), path: "c".into(),
created: Some(1_623_000_000), // 2021 created: Some(1_623_000_000), // 2021
modified: None, modified: None,
library_id: 1, 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. // Build an item at a given unix timestamp (seconds) with a chosen extension.
@@ -453,6 +466,9 @@ mod tests {
created: Some(ts), created: Some(ts),
modified: None, modified: None,
library_id: 1, library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
} }
} }
fn item_at(ts: i64, name: &str) -> memories::MemoryItem { fn item_at(ts: i64, name: &str) -> memories::MemoryItem {
+2
View File
@@ -323,6 +323,7 @@ impl Default for AppState {
face_dao.clone(), face_dao.clone(),
knowledge_dao, knowledge_dao,
persona_dao.clone(), persona_dao.clone(),
user_ai_prefs_dao.clone(),
libraries_vec.clone(), libraries_vec.clone(),
); );
@@ -550,6 +551,7 @@ impl AppState {
face_dao.clone(), face_dao.clone(),
knowledge_dao, knowledge_dao,
persona_dao.clone(), persona_dao.clone(),
Arc::new(Mutex::new(Box::new(SqliteUserAiPrefsDao::new()))),
vec![test_lib], 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()); log::info!("unified_search: translating with model={}", backend.model());
let today = chrono::Utc::now().date_naive(); 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, Ok(sq) => sq,
Err(e) => { Err(e) => {
log::warn!("unified_search: translate_nl_query failed: {e:?}"); log::warn!("unified_search: translate_nl_query failed: {e:?}");