Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e754e149d7 | ||
|
|
73523c6796 | ||
|
|
da3901c564 | ||
|
|
f850c535f1 | ||
|
|
10ed84d7b4 | ||
|
|
696d9fa8f3 | ||
|
|
e84ac82f86 | ||
|
|
a699c940b8 | ||
|
|
aa3c58f051 | ||
|
|
2258a0eab5 | ||
|
|
7f348022d4 | ||
|
|
615941a511 | ||
|
|
af87765bbb | ||
|
|
cd375f18db | ||
|
|
fcaf8487c2 | ||
|
|
98db2a48db | ||
|
|
ab16bc7da6 | ||
|
|
89fc24ceab | ||
|
|
3aa6ce314c |
No files matched your search
@@ -533,6 +533,11 @@ POST /insights/chat (single-turn reply, non-streaming)
|
||||
POST /insights/chat/stream (SSE: text / tool_call / tool_result / truncated / done)
|
||||
GET /insights/chat/history?path=... (rendered transcript with tool invocations)
|
||||
POST /insights/chat/rewind (truncate transcript at a rendered index)
|
||||
|
||||
// User AI Prefs (owner defaults + passive mirror; src/prefs.rs)
|
||||
GET /prefs (current row; auth)
|
||||
PUT /prefs (partial update: omitted=unchanged, null=clear, value=set; auth)
|
||||
GET /timezones (IANA zone list from chrono-tz; unauthenticated)
|
||||
```
|
||||
|
||||
**Request Types:**
|
||||
|
||||
+22
@@ -62,3 +62,25 @@ Insights and pre-computed artifacts grouped by calendar day (daily message summa
|
||||
reel pre-generation). Because it is keyed on the day, re-dating a photo across a day
|
||||
boundary invalidates the derived data for that day, which regenerates on its normal schedule.
|
||||
_Avoid_: cache, nightly output
|
||||
|
||||
### Configuration
|
||||
|
||||
**Owner default**:
|
||||
A per-request default the owner sets for the whole deployment — the model, backend,
|
||||
persona, reel span, or segment cap a client gets when it omits the field. Stored in the
|
||||
single `user_ai_prefs` row, hot-tunable without a redeploy, and global: every client that
|
||||
omits the field gets it, on every device. Never holds secrets.
|
||||
_Avoid_: user prefs, user settings, preferences (ambiguous with device-local client state)
|
||||
|
||||
**Deployment config**:
|
||||
Values the server needs before it can start serving — bind address, database path, media
|
||||
roots, the URLs used to build clients — plus every secret. Lives in `.env` only; changing
|
||||
it is a redeploy, by design.
|
||||
_Avoid_: environment config, system config
|
||||
|
||||
**Hard default**:
|
||||
The env/code fallback a request lands on when neither an explicit param nor a stored
|
||||
owner default supplies the field (e.g. backend `local`, persona `default`, reel span
|
||||
day). It is the floor under the owner-default layer: env changes keep working whenever a
|
||||
default column is NULL.
|
||||
_Avoid_: fallback value, env default
|
||||
@@ -0,0 +1,78 @@
|
||||
# ADR-0002: Owner defaults live in the DB row; .env keeps deployment config and secrets
|
||||
|
||||
**Status**: accepted (2026-09-27)
|
||||
|
||||
## Context
|
||||
|
||||
All configuration lived in `.env`: bind address, media paths, Ollama/llama-swap/OpenRouter
|
||||
URLs, model names, `LLM_BACKEND`, the embedding contract, TTS voices. Two friction points:
|
||||
values the owner actually *tunes* (which model, which backend, which persona, how long a
|
||||
reel is) needed an edit + redeploy to change, and clients had no server-side fallback —
|
||||
"use the default" was expressed by omitting a field, and the default itself was invisible
|
||||
and untouchable at runtime. The `user_ai_prefs` single row (`id = 1`) already existed as
|
||||
a passive mirror of the last client reel request for the nightly pre-gen. #126/#127 gave
|
||||
it an explicit `PUT /prefs` surface and five owner-default columns (`default_model`,
|
||||
`default_backend`, `default_persona_id`, `reel_span`, `reel_max_segments`) — the first
|
||||
batch of non-private configuration moved out of `.env` and into the database.
|
||||
|
||||
## Decision
|
||||
|
||||
Configuration splits into two homes by one rule — *does the server need the value before
|
||||
it can start serving?*
|
||||
|
||||
- **`.env` (deployment config)**: everything read at boot to bind, connect, or open —
|
||||
`BIND_URL`, `DATABASE_URL`, media paths, `OLLAMA_*_URL` / `LLAMA_SWAP_URL`,
|
||||
`LLM_BACKEND`, the embedding contract — and **all secrets** (`OPENROUTER_API_KEY`,
|
||||
`SECRET_KEY`, `SMS_API_TOKEN`). Secrets never go in the database: the SQLite file gets
|
||||
backed up, copied, and attached to bug reports.
|
||||
- **`user_ai_prefs` (owner defaults)**: per-request defaults resolved at request time —
|
||||
the model/backend/persona a client gets when it omits the field, reel span and
|
||||
segment cap, display zone, TTS voice. Hot-tunable via `PUT /prefs`, effective on the
|
||||
next request, no redeploy. This row is the designated home for future non-private,
|
||||
per-request configuration that outgrows `.env`. The table is a **single row per
|
||||
deployment, not a per-user profile** — `user_ai_prefs` is a historical name; the
|
||||
canonical term is *owner defaults* (single-user tool: owner = admin).
|
||||
|
||||
Resolution order at every consumer: **explicit request param → stored owner default →
|
||||
hard default** (the env/code fallback). An explicit request always wins, so any client
|
||||
can override the owner's default per request. The env value stays the floor under the
|
||||
DB: a NULL `default_model` means "whatever the Ollama/llama-swap primary is", so env
|
||||
changes keep working.
|
||||
|
||||
**Privacy posture is safe-by-default.** `default_backend` is the one owner default with
|
||||
network-egress consequences: `hybrid` routes insight generation and chat to OpenRouter.
|
||||
With no row or a NULL column, the hard default is `local` — data stays on the LAN.
|
||||
Leaving the network requires an explicit owner action (storing `hybrid`), and any client
|
||||
can still pin itself by sending `backend` explicitly. Owner defaults therefore split
|
||||
into two sensitivity classes: **harmless tuning** (span, segments, display zone, voice,
|
||||
model, persona) and **privacy/security** (`default_backend`), which any admin UI must
|
||||
surface with a warning, not a quiet dropdown.
|
||||
|
||||
## Considered options
|
||||
|
||||
- **Everything in `.env`** (status quo): safe, but tuning a model default means edit +
|
||||
redeploy, and clients have no server-side default to fall back to — a picker's
|
||||
"server default" is just the env value, invisible and unchangeable at runtime.
|
||||
Rejected for per-request defaults.
|
||||
- **Everything in the DB**: boot-time values (bind address, DB path, the URLs used to
|
||||
build the HTTP clients) become chicken-and-egg, and secrets sit next to tuning values
|
||||
in a file that gets copied around. Rejected.
|
||||
- **A separate `system_config` table**: cleaner name, but a second single-row table
|
||||
would be a synonym with extra migrations; the row and the `PUT /prefs` surface already
|
||||
exist. Rejected — the row keeps its name, its *term* becomes owner defaults.
|
||||
|
||||
## Non-obvious rules worth remembering
|
||||
|
||||
- **Owner defaults are global to the deployment.** Every client that omits a field gets
|
||||
the stored value, on every device. Storing `default_backend=hybrid` changes the
|
||||
mobile app's behavior even for users who never touch the setting; a client that must
|
||||
stay local should send `backend` explicitly (tracked for FileViewer-React #33).
|
||||
- **A stored value that is no longer consumable fails at write time, not request time** —
|
||||
`PUT /prefs` validates `default_backend` against the canonical `BackendKind::parse`
|
||||
and `reel_span` against the span enum before writing, so a bad default cannot 500
|
||||
every request later.
|
||||
- **The passive reel mirror (`capture_prefs`) may only write the fields it owns** (voice,
|
||||
library, display zone, effective span/segments); it carries the AI owner-defaults
|
||||
through unchanged, because the upsert overwrites every column.
|
||||
- **NULL means "use the hard default", not "unset the env"** — the DB layer sits
|
||||
*above* env, never instead of it.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Post-migration span capture + parity diff (ticket #143, map #135)
|
||||
|
||||
The "after" counterpart of `../pre-migration/` (#137). It is captured the same
|
||||
way, against the same local Jaeger, with the same request set, and diffed
|
||||
against `../pre-migration/span-inventory.json`.
|
||||
|
||||
## What was run
|
||||
|
||||
- **Revision:** `feature/tracing-migration` @ `f510940`, rebased onto master
|
||||
`8284509` (dev/e2e server). Release build. No source patch was needed:
|
||||
master's `LOCAL_BIND_URL=` disables the secondary `:8088` listener.
|
||||
- **Exporter:** the same local Jaeger all-in-one (OTLP/gRPC `:14317`). Traces
|
||||
were filtered by capture start time, because the container still holds the
|
||||
baseline traces.
|
||||
- **DB:** a fresh `.backup` of the live `auth.db` (21,298 `image_exif` rows,
|
||||
4 libraries, max job id 379, so the agentic job is again **380**). Output
|
||||
dirs point at temp. `REEL_PREGEN_ENABLED=0`, `REEL_CACHE_SWEEP_ENABLED=0`.
|
||||
- **LLM:** the live local stack (`LLM_BACKEND=llamacpp`,
|
||||
`qwen3.8-27b-abliterated`).
|
||||
- **Requests:** the baseline table, replayed by `replay.sh`: photos, memories,
|
||||
agentic (202, polled to `completed`), chat stream (87 deltas + `done`), and
|
||||
the `traceparent: 00-1111…-2222…-01` probe.
|
||||
|
||||
Two captures were taken. Run 1 found two gaps, which are fixed in `f510940`
|
||||
(see "Gaps found and fixed"). Run 2, the files here, is the capture of record.
|
||||
|
||||
## Parity result
|
||||
|
||||
| Check | Before (#137) | After | Verdict |
|
||||
|---|---|---|---|
|
||||
| Operation names | — | every baseline op present (`find_relevant_events_hybrid` is a model-chosen tool; run 1 hit it, run 2 didn't) | ✅ preserved |
|
||||
| Attribute keys (semantic) | per `pre-migration/span-inventory.json` | **identical** for every common op (`list_photos`' ~27 request attrs, `ai.insight.generate_agentic`, `ai.rag.*`, `generate_video_thumbnail`, `db.*`, `llm.request`) | ✅ preserved |
|
||||
| `llm.request` | kind=client; `llm.model` / `llm.provider` / `llm.operation` / `llm.tokens.*` / `http.status_code` | same, on all 12 LLM spans | ✅ |
|
||||
| `llm.backend` | absent from every baseline span (it lives on the async-turn path, which neither capture exercises) | same | ✅ no loss |
|
||||
| Incoming `traceparent` | `list_photos` CHILD_OF remote `2222…` in trace `1111…` | the `GET /photos` root span is CHILD_OF remote `2222…`, with `list_photos` + DB spans under it, all in trace `1111…` | ✅ intact |
|
||||
| DB span nesting | most DB spans were single-span traces | **no DB span is a root**; each nests under its request / feature / job span | ✅ fixed |
|
||||
| Agentic 202 → background | two traces (`948d2756…` / `9b520713…`) | one 52-span trace: `POST …/agentic` → `http.insights.generate_agentic` → `ai.insight.generate_agentic` → `ai.agentic.loop` → `llm.request` / `ai.rag.*` / DB | ✅ fixed |
|
||||
| Streaming chat | fragmented, no handler span | one trace: `POST /insights/chat/stream` → `ai.chat.turn.stream` → `llm.request (chat_stream)` + DB | ✅ fixed |
|
||||
| Thumbnail sweep | 222 `generate_video_thumbnail` in 1 trace | 414 in 1 trace, all with one parent (the `creating thumbnails` root, still open when capture stopped) | ✅ |
|
||||
|
||||
**Roots in the after capture:** only request roots (`GET /photos`,
|
||||
`GET /memories`, `POST …/agentic`, `POST /insights/chat/stream`,
|
||||
`GET /insights/generation/status`) and the background root
|
||||
`watcher.initial_probe`. **No orphans.**
|
||||
|
||||
## Intentional changes (vs baseline)
|
||||
|
||||
- **New per-request root spans** (`GET /photos`, …) from `tracing-actix-web`
|
||||
(#139). Operation spans (`list_photos`, …) now sit one level under them.
|
||||
- **New spans:** `ai.chat.turn.stream` (#140), `watcher.initial_probe` and the
|
||||
other job roots (#142); `db.query.get_job_by_id` shows up because the
|
||||
replay now polls job status.
|
||||
- **DB tracer scope:** `db` → `image-server`. DB spans come from the
|
||||
subscriber's tracer (#141). The span names and `db.*` attributes are unchanged.
|
||||
- **Extra per-span metadata** from tracing-opentelemetry, excluded from the key
|
||||
diff above: `code.file.path` / `code.line.number` / `code.module.name`,
|
||||
`thread.id` / `thread.name`, `target`, `busy_ns` / `idle_ns`.
|
||||
- **Removed:** the zero-length `file write` span in `upload_image` (#141). It
|
||||
wasn't exercised by either capture.
|
||||
|
||||
## Gaps found and fixed (run 1 → `f510940`)
|
||||
|
||||
1. **Agentic background still split off.** Both insight handlers run the
|
||||
generation in a nested `tokio::task::spawn` (for panic isolation) that
|
||||
wasn't instrumented, so `ai.insight.generate_agentic` was a root.
|
||||
2. **Watcher boot probe.** `count_for_library` ran before the first tick span,
|
||||
leaving 4 root DB spans. It now runs under `watcher.initial_probe`.
|
||||
|
||||
## Files
|
||||
|
||||
- `traces.json` — Jaeger `/api/traces` dump for run 2.
|
||||
- `span-inventory.json` — the baseline's schema, plus `parents` / `root_count`.
|
||||
- `diff-report.txt` — the full `analyze.py` output (before/after diff, roots,
|
||||
ancestry chains, traceparent chain, per-`llm.request` attributes).
|
||||
- `analyze.py`, `replay.sh` — reproduce with
|
||||
`python3 analyze.py <dir-with traces-after.json + inventory-before.json>`.
|
||||
|
||||
## Full suite on `f510940`
|
||||
|
||||
`cargo build` ✅, `cargo clippy --all-targets` 0 warnings ✅, `cargo fmt --check` ✅,
|
||||
`cargo test` ✅ (690 lib + 775 integration + 5 OTel regression + 4 doc).
|
||||
@@ -0,0 +1,149 @@
|
||||
"""#143 parity analysis: after-capture Jaeger dump vs the #137 baseline.
|
||||
|
||||
Writes span-inventory.json (baseline schema + nesting columns) and prints a
|
||||
before/after diff plus targeted nesting / propagation checks.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
W = sys.argv[1]
|
||||
after = json.load(open(f"{W}/traces-after.json"))["data"]
|
||||
before_inv = {r["operation"]: r for r in json.load(open(f"{W}/inventory-before.json"))}
|
||||
|
||||
# Attributes tracing-opentelemetry adds to every span (location / thread /
|
||||
# timing metadata). Reported separately so the diff shows semantic changes.
|
||||
TRACING_EXTRAS = {
|
||||
"busy_ns", "idle_ns", "target", "thread.id", "thread.name",
|
||||
"code.file.path", "code.line.number", "code.module.name",
|
||||
}
|
||||
# Spans produced by my auth probes, not by the baseline request set.
|
||||
PROBE_NOISE = set()
|
||||
|
||||
spans = {}
|
||||
by_trace = defaultdict(list)
|
||||
for t in after:
|
||||
for s in t["spans"]:
|
||||
s["_tags"] = {kv["key"]: kv["value"] for kv in s["tags"]}
|
||||
spans[s["spanID"]] = s
|
||||
by_trace[s["traceID"]].append(s)
|
||||
|
||||
|
||||
def parent(s):
|
||||
for r in s.get("references", []):
|
||||
if r["refType"] == "CHILD_OF":
|
||||
return r["spanID"], r["traceID"]
|
||||
return None, None
|
||||
|
||||
|
||||
def op(sid):
|
||||
return spans[sid]["operationName"] if sid in spans else None
|
||||
|
||||
|
||||
# Inventory in the baseline's schema, plus nesting columns.
|
||||
inv = defaultdict(lambda: {"span_count": 0, "traces": set(), "kinds": set(), "scopes": set(),
|
||||
"statuses": set(), "keys": set(), "parents": defaultdict(int),
|
||||
"orphans": 0})
|
||||
for s in spans.values():
|
||||
r = inv[s["operationName"]]
|
||||
r["span_count"] += 1
|
||||
r["traces"].add(s["traceID"])
|
||||
r["kinds"].add(s["_tags"].get("span.kind", "internal"))
|
||||
r["scopes"].add(s["_tags"].get("otel.scope.name", "?"))
|
||||
r["statuses"].add(s["_tags"].get("otel.status_code", "(none)"))
|
||||
r["keys"].update(s["_tags"])
|
||||
pid, _ = parent(s)
|
||||
if pid is None:
|
||||
r["orphans"] += 1
|
||||
else:
|
||||
r["parents"][op(pid) or "(remote)"] += 1
|
||||
|
||||
inventory = []
|
||||
for name, r in sorted(inv.items(), key=lambda kv: -kv[1]["span_count"]):
|
||||
inventory.append({
|
||||
"operation": name,
|
||||
"span_count": r["span_count"],
|
||||
"trace_count": len(r["traces"]),
|
||||
"kind": "/".join(sorted(r["kinds"])),
|
||||
"scope": "/".join(sorted(r["scopes"])),
|
||||
"status": "/".join(sorted(r["statuses"])),
|
||||
"attribute_keys": sorted(r["keys"] - TRACING_EXTRAS),
|
||||
"parents": dict(r["parents"]),
|
||||
"root_count": r["orphans"],
|
||||
})
|
||||
json.dump(inventory, open(f"{W}/span-inventory.json", "w"), indent=2)
|
||||
|
||||
print("== Operations: before -> after ==")
|
||||
before_ops, after_ops = set(before_inv), set(inv)
|
||||
print("only before:", sorted(before_ops - after_ops))
|
||||
print("only after: ", sorted(after_ops - before_ops))
|
||||
print()
|
||||
print("== Common operations: kind / scope / attribute-key changes ==")
|
||||
for name in sorted(before_ops & after_ops):
|
||||
b, a = before_inv[name], next(x for x in inventory if x["operation"] == name)
|
||||
bk, ak = set(b["attribute_keys"]), set(a["attribute_keys"])
|
||||
changes = []
|
||||
if b["kind"] != a["kind"]:
|
||||
changes.append(f"kind {b['kind']}->{a['kind']}")
|
||||
if b["scope"] != a["scope"]:
|
||||
changes.append(f"scope {b['scope']}->{a['scope']}")
|
||||
if bk - ak:
|
||||
changes.append(f"LOST {sorted(bk - ak)}")
|
||||
if ak - bk:
|
||||
changes.append(f"added {sorted(ak - bk)}")
|
||||
nest = f"traces {b['trace_count']}/{b['span_count']} -> {a['trace_count']}/{a['span_count']}, roots={a['root_count']}"
|
||||
print(f"{name}: {'; '.join(changes) or 'same'} | {nest}")
|
||||
|
||||
print()
|
||||
print("== Roots in the after capture (span with no parent) ==")
|
||||
for s in spans.values():
|
||||
if parent(s)[0] is None:
|
||||
print(f" {s['operationName']} trace={s['traceID'][:8]} spans_in_trace={len(by_trace[s['traceID']])}")
|
||||
|
||||
|
||||
def chain(s):
|
||||
out = [s["operationName"]]
|
||||
pid, _ = parent(s)
|
||||
while pid:
|
||||
if pid not in spans:
|
||||
out.append(f"(remote {pid})")
|
||||
break
|
||||
out.append(spans[pid]["operationName"])
|
||||
pid, _ = parent(spans[pid])
|
||||
return " <- ".join(out)
|
||||
|
||||
|
||||
print()
|
||||
print("== Ancestry of key spans ==")
|
||||
seen = set()
|
||||
for s in spans.values():
|
||||
n = s["operationName"]
|
||||
key = (n, s["_tags"].get("llm.operation"))
|
||||
if n in ("llm.request", "ai.agentic.loop", "ai.insight.generate_agentic",
|
||||
"db.insert.store_insight", "db.update.update_training_messages",
|
||||
"ai.chat.turn.stream", "list_photos", "list_memories",
|
||||
"generate_video_thumbnail") and key not in seen:
|
||||
seen.add(key)
|
||||
extra = f" [llm.operation={key[1]}]" if key[1] else ""
|
||||
print(f" {chain(s)}{extra}")
|
||||
|
||||
print()
|
||||
print("== traceparent probe ==")
|
||||
probe = [s for s in spans.values() if s["traceID"].startswith("11111111")]
|
||||
for s in sorted(probe, key=lambda s: s["startTime"]):
|
||||
print(f" {chain(s)} trace={s['traceID']}")
|
||||
|
||||
print()
|
||||
print("== llm.request attribute presence ==")
|
||||
for s in spans.values():
|
||||
if s["operationName"] == "llm.request":
|
||||
t = s["_tags"]
|
||||
print(f" op={t.get('llm.operation')} model={t.get('llm.model')} provider={t.get('llm.provider')} "
|
||||
f"status={t.get('http.status_code')} tokens_in={t.get('llm.tokens.input')} "
|
||||
f"parent={op(parent(s)[0])}")
|
||||
print()
|
||||
print("== turn / insight spans: llm.backend + llm.model ==")
|
||||
for s in spans.values():
|
||||
if s["operationName"] in ("ai.chat.turn.stream", "ai.insight.generate_agentic"):
|
||||
t = s["_tags"]
|
||||
print(f" {s['operationName']}: llm.backend={t.get('llm.backend')} llm.model={t.get('llm.model')} backend={t.get('backend')} model={t.get('model')}")
|
||||
@@ -0,0 +1,108 @@
|
||||
== Operations: before -> after ==
|
||||
only before: ['db.query.find_relevant_events_hybrid']
|
||||
only after: ['GET /insights/generation/status', 'GET /memories', 'GET /photos', 'POST /insights/chat/stream', 'POST /insights/generate/agentic', 'ai.chat.turn.stream', 'db.query.get_job_by_id', 'watcher.initial_probe']
|
||||
|
||||
== Common operations: kind / scope / attribute-key changes ==
|
||||
ai.agentic.loop: same | traces 1/1 -> 1/1, roots=0
|
||||
ai.insight.generate_agentic: same | traces 1/1 -> 1/1, roots=0
|
||||
ai.rag.search_daily_summaries: same | traces 1/1 -> 1/2, roots=0
|
||||
db.insert.create_job: scope db->image-server | traces 1/1 -> 1/1, roots=0
|
||||
db.insert.store_insight: scope db->image-server | traces 1/1 -> 1/1, roots=0
|
||||
db.insert.upsert_entity: scope db->image-server | traces 1/2 -> 1/3, roots=0
|
||||
db.insert.upsert_fact: scope db->image-server | traces 1/1 -> 1/2, roots=0
|
||||
db.insert.upsert_photo_link: scope db->image-server | traces 1/1 -> 1/2, roots=0
|
||||
db.query.count_for_library: scope db->image-server | traces 4/4 -> 1/4, roots=0
|
||||
db.query.faces_for_hash: scope db->image-server | traces 1/1 -> 1/1, roots=0
|
||||
db.query.find_similar_summaries_with_time_weight: scope db->image-server | traces 1/1 -> 1/2, roots=0
|
||||
db.query.get_active_job: scope db->image-server | traces 1/1 -> 1/1, roots=0
|
||||
db.query.get_current_insight_for_library: scope db->image-server | traces 1/1 -> 1/1, roots=0
|
||||
db.query.get_event_count: scope db->image-server | traces 2/2 -> 2/2, roots=0
|
||||
db.query.get_exif: scope db->image-server | traces 2/2 -> 1/3, roots=0
|
||||
db.query.get_links_for_photo: scope db->image-server | traces 1/1 -> 1/1, roots=0
|
||||
db.query.get_location_count: scope db->image-server | traces 2/2 -> 2/2, roots=0
|
||||
db.query.get_memories_in_window: scope db->image-server | traces 1/4 -> 1/4, roots=0
|
||||
db.query.get_persona: scope db->image-server | traces 4/4 -> 2/4, roots=0
|
||||
db.query.get_prefs: scope db->image-server | traces 4/4 -> 3/4, roots=0
|
||||
db.query.get_tag_counts_batch: scope db->db/image-server | traces 1/1 -> 2/4, roots=0
|
||||
db.query.get_tags_for_path: scope db->image-server | traces 1/1 -> 1/1, roots=0
|
||||
db.query.has_any_faces: scope db->image-server | traces 2/2 -> 2/2, roots=0
|
||||
db.query.has_any_summaries: scope db->image-server | traces 2/2 -> 2/2, roots=0
|
||||
db.query.list_entities: scope db->image-server | traces 1/2 -> 1/4, roots=0
|
||||
db.query.list_rel_paths_for_libraries: scope db->db/image-server | traces 1/1 -> 2/4, roots=0
|
||||
db.query.resolve_content_hash: scope db->image-server | traces 1/1 -> 1/1, roots=0
|
||||
db.update.cancel_active_jobs: scope db->image-server | traces 1/1 -> 1/1, roots=0
|
||||
db.update.complete_job: scope db->image-server | traces 1/1 -> 1/1, roots=0
|
||||
db.update.update_facts_insight_id: scope db->image-server | traces 1/1 -> 1/1, roots=0
|
||||
db.update.update_training_messages: scope db->image-server | traces 1/1 -> 1/1, roots=0
|
||||
generate_video_thumbnail: same | traces 1/222 -> 1/414, roots=0
|
||||
http.insights.generate_agentic: same | traces 1/1 -> 1/1, roots=0
|
||||
list_memories: same | traces 1/1 -> 1/1, roots=0
|
||||
list_photos: same | traces 1/1 -> 2/4, roots=0
|
||||
llm.request: same | traces 3/8 -> 2/12, roots=0
|
||||
|
||||
== Roots in the after capture (span with no parent) ==
|
||||
GET /insights/generation/status trace=4e4c40ef spans_in_trace=2
|
||||
GET /insights/generation/status trace=9b6f5cad spans_in_trace=2
|
||||
GET /insights/generation/status trace=92fe175c spans_in_trace=2
|
||||
GET /insights/generation/status trace=a1c2c449 spans_in_trace=2
|
||||
GET /insights/generation/status trace=73ad5d54 spans_in_trace=2
|
||||
GET /insights/generation/status trace=96ba44fa spans_in_trace=2
|
||||
GET /insights/generation/status trace=63be2acf spans_in_trace=2
|
||||
POST /insights/chat/stream trace=119b1ad6 spans_in_trace=11
|
||||
GET /insights/generation/status trace=d0f13d49 spans_in_trace=2
|
||||
GET /insights/generation/status trace=01e0824c spans_in_trace=2
|
||||
watcher.initial_probe trace=09884c56 spans_in_trace=5
|
||||
GET /insights/generation/status trace=4a47ba01 spans_in_trace=2
|
||||
POST /insights/generate/agentic trace=881c56c0 spans_in_trace=52
|
||||
GET /insights/generation/status trace=87857a0b spans_in_trace=2
|
||||
GET /insights/generation/status trace=e1cf97cb spans_in_trace=2
|
||||
GET /insights/generation/status trace=5f02a109 spans_in_trace=2
|
||||
GET /insights/generation/status trace=6fb52a67 spans_in_trace=2
|
||||
GET /insights/generation/status trace=2512bbae spans_in_trace=2
|
||||
GET /photos trace=4cd7207a spans_in_trace=4
|
||||
GET /memories trace=0c26f842 spans_in_trace=7
|
||||
GET /insights/generation/status trace=6844193f spans_in_trace=2
|
||||
|
||||
== Ancestry of key spans ==
|
||||
list_photos <- (remote 2222222222222222)
|
||||
llm.request <- ai.chat.turn.stream <- POST /insights/chat/stream [llm.operation=chat_stream]
|
||||
db.update.update_training_messages <- ai.chat.turn.stream <- POST /insights/chat/stream
|
||||
ai.chat.turn.stream <- POST /insights/chat/stream
|
||||
llm.request <- ai.rag.search_daily_summaries <- ai.agentic.loop <- ai.insight.generate_agentic <- http.insights.generate_agentic <- POST /insights/generate/agentic [llm.operation=embeddings]
|
||||
llm.request <- ai.agentic.loop <- ai.insight.generate_agentic <- http.insights.generate_agentic <- POST /insights/generate/agentic [llm.operation=chat]
|
||||
ai.agentic.loop <- ai.insight.generate_agentic <- http.insights.generate_agentic <- POST /insights/generate/agentic
|
||||
db.insert.store_insight <- ai.insight.generate_agentic <- http.insights.generate_agentic <- POST /insights/generate/agentic
|
||||
ai.insight.generate_agentic <- http.insights.generate_agentic <- POST /insights/generate/agentic
|
||||
list_memories <- GET /memories
|
||||
generate_video_thumbnail <- (remote 4b78d96000f31655)
|
||||
|
||||
== traceparent probe ==
|
||||
list_photos <- (remote 2222222222222222) trace=11111111111111111111111111111111
|
||||
db.query.list_rel_paths_for_libraries <- list_photos <- (remote 2222222222222222) trace=11111111111111111111111111111111
|
||||
db.query.get_tag_counts_batch <- list_photos <- (remote 2222222222222222) trace=11111111111111111111111111111111
|
||||
GET /photos <- (remote 2222222222222222) trace=11111111111111111111111111111111
|
||||
list_photos <- GET /photos <- (remote 2222222222222222) trace=11111111111111111111111111111111
|
||||
db.query.list_rel_paths_for_libraries <- list_photos <- GET /photos <- (remote 2222222222222222) trace=11111111111111111111111111111111
|
||||
db.query.get_tag_counts_batch <- list_photos <- GET /photos <- (remote 2222222222222222) trace=11111111111111111111111111111111
|
||||
GET /photos <- (remote 2222222222222222) trace=11111111111111111111111111111111
|
||||
list_photos <- GET /photos <- (remote 2222222222222222) trace=11111111111111111111111111111111
|
||||
db.query.list_rel_paths_for_libraries <- list_photos <- GET /photos <- (remote 2222222222222222) trace=11111111111111111111111111111111
|
||||
db.query.get_tag_counts_batch <- list_photos <- GET /photos <- (remote 2222222222222222) trace=11111111111111111111111111111111
|
||||
|
||||
== llm.request attribute presence ==
|
||||
op=chat_stream model=qwen3.8-27b-abliterated provider=llamacpp status=200 tokens_in=11148 parent=ai.chat.turn.stream
|
||||
op=embeddings model=embed provider=llamacpp status=200 tokens_in=None parent=ai.rag.search_daily_summaries
|
||||
op=chat model=qwen3.8-27b-abliterated provider=llamacpp status=200 tokens_in=10289 parent=ai.agentic.loop
|
||||
op=chat model=qwen3.8-27b-abliterated provider=llamacpp status=200 tokens_in=10480 parent=ai.agentic.loop
|
||||
op=embeddings model=embed provider=llamacpp status=200 tokens_in=None parent=ai.agentic.loop
|
||||
op=embeddings model=embed provider=llamacpp status=200 tokens_in=None parent=ai.agentic.loop
|
||||
op=chat model=qwen3.8-27b-abliterated provider=llamacpp status=200 tokens_in=5089 parent=ai.agentic.loop
|
||||
op=chat model=qwen3.8-27b-abliterated provider=llamacpp status=200 tokens_in=10672 parent=ai.agentic.loop
|
||||
op=chat model=qwen3.8-27b-abliterated provider=llamacpp status=200 tokens_in=10911 parent=ai.agentic.loop
|
||||
op=chat model=qwen3.8-27b-abliterated provider=llamacpp status=200 tokens_in=10340 parent=ai.agentic.loop
|
||||
op=chat model=qwen3.8-27b-abliterated provider=llamacpp status=200 tokens_in=9858 parent=ai.agentic.loop
|
||||
op=embeddings model=embed provider=llamacpp status=200 tokens_in=None parent=ai.rag.search_daily_summaries
|
||||
|
||||
== turn / insight spans: llm.backend + llm.model ==
|
||||
ai.chat.turn.stream: llm.backend=None llm.model=None backend=None model=None
|
||||
ai.insight.generate_agentic: llm.backend=None llm.model=None backend=local model=qwen3.8-27b-abliterated
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
# Replays the #137 baseline request set against the after-capture server.
|
||||
set -u
|
||||
W=$(dirname "$0"); B=http://127.0.0.1:18080
|
||||
T=$(python3 $W/mint_jwt.py); H="Authorization: Bearer $T"
|
||||
echo "1) photos"; curl -s -o /dev/null -w "%{http_code}\n" -H "$H" "$B/photos?path=Colleen&recursive=true&limit=20"
|
||||
echo "2) memories"; curl -s -o /dev/null -w "%{http_code}\n" -H "$H" "$B/memories?path=.&recursive=true"
|
||||
echo "3) agentic"; R=$(curl -s -H "$H" -H 'Content-Type: application/json' -d '{"file_path":"Photo06172345.jpg","backend":"local"}' "$B/insights/generate/agentic"); echo "$R"
|
||||
JOB=$(echo "$R" | jq -r '.job_id // empty')
|
||||
if [ -n "$JOB" ]; then
|
||||
for i in $(seq 1 120); do S=$(curl -s -H "$H" "$B/insights/generation/status?job_id=$JOB" | jq -r .status); [ "$S" != "running" ] && [ "$S" != "pending" ] && break; sleep 5; done
|
||||
echo " job $JOB -> $S"
|
||||
fi
|
||||
echo "4) chat stream"; curl -sN -H "$H" -H 'Content-Type: application/json' -d '{"file_path":"Photo06172345.jpg","user_message":"What was I wearing in this photo?","backend":"local"}' "$B/insights/chat/stream" > $W/chat.sse; echo " deltas=$(grep -c '^event: text' $W/chat.sse) done=$(grep -c '^event: done' $W/chat.sse) errors=$(grep -c '^event: error' $W/chat.sse)"
|
||||
echo "5) traceparent probe"; curl -s -o /dev/null -w "%{http_code}\n" -H "$H" -H 'traceparent: 00-11111111111111111111111111111111-2222222222222222-01' "$B/photos?path=Colleen&recursive=true&limit=5"
|
||||
@@ -0,0 +1,918 @@
|
||||
[
|
||||
{
|
||||
"operation": "generate_video_thumbnail",
|
||||
"span_count": 414,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "(none)",
|
||||
"attribute_keys": [
|
||||
"file-name",
|
||||
"library",
|
||||
"otel.scope.name",
|
||||
"span.kind",
|
||||
"type"
|
||||
],
|
||||
"parents": {
|
||||
"(remote)": 414
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_job_by_id",
|
||||
"span_count": 16,
|
||||
"trace_count": 16,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"GET /insights/generation/status": 16
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "GET /insights/generation/status",
|
||||
"span_count": 16,
|
||||
"trace_count": 16,
|
||||
"kind": "server",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"http.client_ip",
|
||||
"http.flavor",
|
||||
"http.host",
|
||||
"http.method",
|
||||
"http.route",
|
||||
"http.scheme",
|
||||
"http.status_code",
|
||||
"http.target",
|
||||
"http.user_agent",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"request_id",
|
||||
"span.kind",
|
||||
"trace_id"
|
||||
],
|
||||
"parents": {},
|
||||
"root_count": 16
|
||||
},
|
||||
{
|
||||
"operation": "llm.request",
|
||||
"span_count": 12,
|
||||
"trace_count": 2,
|
||||
"kind": "client",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"http.status_code",
|
||||
"llm.model",
|
||||
"llm.operation",
|
||||
"llm.provider",
|
||||
"llm.tokens.input",
|
||||
"llm.tokens.output",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"ai.chat.turn.stream": 1,
|
||||
"ai.rag.search_daily_summaries": 2,
|
||||
"ai.agentic.loop": 9
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_tag_counts_batch",
|
||||
"span_count": 4,
|
||||
"trace_count": 2,
|
||||
"kind": "internal",
|
||||
"scope": "db/image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"file_count",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"list_photos": 4
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "list_photos",
|
||||
"span_count": 4,
|
||||
"trace_count": 2,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"camera_make",
|
||||
"camera_model",
|
||||
"date_from",
|
||||
"date_to",
|
||||
"exclude_tag_ids",
|
||||
"file_count",
|
||||
"gps_lat",
|
||||
"gps_lon",
|
||||
"gps_radius_km",
|
||||
"lens_model",
|
||||
"library",
|
||||
"media_type",
|
||||
"optimization.batch_tags",
|
||||
"optimization.db_sort",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"pagination.enabled",
|
||||
"pagination.limit",
|
||||
"pagination.offset",
|
||||
"path",
|
||||
"recursive",
|
||||
"returned_count",
|
||||
"sort",
|
||||
"span.kind",
|
||||
"tag_filter_mode",
|
||||
"tag_ids",
|
||||
"total_count"
|
||||
],
|
||||
"parents": {
|
||||
"(remote)": 1,
|
||||
"GET /photos": 3
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.query.list_rel_paths_for_libraries",
|
||||
"span_count": 4,
|
||||
"trace_count": 2,
|
||||
"kind": "internal",
|
||||
"scope": "db/image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"list_photos": 4
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_persona",
|
||||
"span_count": 4,
|
||||
"trace_count": 2,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"ai.chat.turn.stream": 1,
|
||||
"http.insights.generate_agentic": 1,
|
||||
"ai.agentic.loop": 1,
|
||||
"ai.insight.generate_agentic": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_prefs",
|
||||
"span_count": 4,
|
||||
"trace_count": 3,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"POST /insights/chat/stream": 1,
|
||||
"ai.insight.generate_agentic": 1,
|
||||
"http.insights.generate_agentic": 1,
|
||||
"list_memories": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.query.count_for_library",
|
||||
"span_count": 4,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"watcher.initial_probe": 4
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.query.list_entities",
|
||||
"span_count": 4,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"ai.agentic.loop": 4
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_memories_in_window",
|
||||
"span_count": 4,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"list_memories": 4
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "GET /photos",
|
||||
"span_count": 3,
|
||||
"trace_count": 2,
|
||||
"kind": "server",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"http.client_ip",
|
||||
"http.flavor",
|
||||
"http.host",
|
||||
"http.method",
|
||||
"http.route",
|
||||
"http.scheme",
|
||||
"http.status_code",
|
||||
"http.target",
|
||||
"http.user_agent",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"request_id",
|
||||
"span.kind",
|
||||
"trace_id"
|
||||
],
|
||||
"parents": {
|
||||
"(remote)": 2
|
||||
},
|
||||
"root_count": 1
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_exif",
|
||||
"span_count": 3,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"ai.agentic.loop": 2,
|
||||
"ai.insight.generate_agentic": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.insert.upsert_entity",
|
||||
"span_count": 3,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"ai.agentic.loop": 2,
|
||||
"ai.insight.generate_agentic": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.query.has_any_faces",
|
||||
"span_count": 2,
|
||||
"trace_count": 2,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"ai.chat.turn.stream": 1,
|
||||
"ai.insight.generate_agentic": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_location_count",
|
||||
"span_count": 2,
|
||||
"trace_count": 2,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"ai.chat.turn.stream": 1,
|
||||
"ai.insight.generate_agentic": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_event_count",
|
||||
"span_count": 2,
|
||||
"trace_count": 2,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"ai.chat.turn.stream": 1,
|
||||
"ai.insight.generate_agentic": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.query.has_any_summaries",
|
||||
"span_count": 2,
|
||||
"trace_count": 2,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"ai.chat.turn.stream": 1,
|
||||
"ai.insight.generate_agentic": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.query.find_similar_summaries_with_time_weight",
|
||||
"span_count": 2,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"ai.rag.search_daily_summaries": 2
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "ai.rag.search_daily_summaries",
|
||||
"span_count": 2,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"date",
|
||||
"limit",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"query",
|
||||
"results_count",
|
||||
"span.kind",
|
||||
"time_weighted"
|
||||
],
|
||||
"parents": {
|
||||
"ai.agentic.loop": 2
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.insert.upsert_photo_link",
|
||||
"span_count": 2,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"ai.agentic.loop": 2
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.insert.upsert_fact",
|
||||
"span_count": 2,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"ai.agentic.loop": 2
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.update.update_training_messages",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"ai.chat.turn.stream": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "ai.chat.turn.stream",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"file_path",
|
||||
"library_id",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"POST /insights/chat/stream": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "POST /insights/chat/stream",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "server",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"http.client_ip",
|
||||
"http.flavor",
|
||||
"http.host",
|
||||
"http.method",
|
||||
"http.route",
|
||||
"http.scheme",
|
||||
"http.status_code",
|
||||
"http.target",
|
||||
"http.user_agent",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"request_id",
|
||||
"span.kind",
|
||||
"trace_id"
|
||||
],
|
||||
"parents": {},
|
||||
"root_count": 1
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_current_insight_for_library",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"ai.chat.turn.stream": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "watcher.initial_probe",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "(none)",
|
||||
"attribute_keys": [
|
||||
"otel.scope.name",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {},
|
||||
"root_count": 1
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_tags_for_path",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"path",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"ai.insight.generate_agentic": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_links_for_photo",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"ai.agentic.loop": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_active_job",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"http.insights.generate_agentic": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "ai.agentic.loop",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"iterations_used",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"ai.insight.generate_agentic": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.insert.store_insight",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"ai.insight.generate_agentic": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.update.complete_job",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"http.insights.generate_agentic": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.update.update_facts_insight_id",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"ai.insight.generate_agentic": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.query.resolve_content_hash",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"ai.agentic.loop": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "ai.insight.generate_agentic",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"backend",
|
||||
"file_path",
|
||||
"images_inline",
|
||||
"max_iterations",
|
||||
"model",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"http.insights.generate_agentic": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "http.insights.generate_agentic",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"backend",
|
||||
"file_path",
|
||||
"job_id",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"POST /insights/generate/agentic": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "POST /insights/generate/agentic",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "server",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"http.client_ip",
|
||||
"http.flavor",
|
||||
"http.host",
|
||||
"http.method",
|
||||
"http.route",
|
||||
"http.scheme",
|
||||
"http.status_code",
|
||||
"http.target",
|
||||
"http.user_agent",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"request_id",
|
||||
"span.kind",
|
||||
"trace_id"
|
||||
],
|
||||
"parents": {},
|
||||
"root_count": 1
|
||||
},
|
||||
{
|
||||
"operation": "db.query.faces_for_hash",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"content_hash",
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"ai.agentic.loop": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.update.cancel_active_jobs",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"http.insights.generate_agentic": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "db.insert.create_job",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"http.insights.generate_agentic": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "list_memories",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
],
|
||||
"parents": {
|
||||
"GET /memories": 1
|
||||
},
|
||||
"root_count": 0
|
||||
},
|
||||
{
|
||||
"operation": "GET /memories",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "server",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"http.client_ip",
|
||||
"http.flavor",
|
||||
"http.host",
|
||||
"http.method",
|
||||
"http.route",
|
||||
"http.scheme",
|
||||
"http.status_code",
|
||||
"http.target",
|
||||
"http.user_agent",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"request_id",
|
||||
"span.kind",
|
||||
"trace_id"
|
||||
],
|
||||
"parents": {},
|
||||
"root_count": 1
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
{"data":[{"traceID":"4e4c40ef185bcdc4bcf4dacbcd661891","spans":[{"traceID":"4e4c40ef185bcdc4bcf4dacbcd661891","spanID":"91c3816dc20cf18a","operationName":"db.query.get_job_by_id","references":[{"refType":"CHILD_OF","traceID":"4e4c40ef185bcdc4bcf4dacbcd661891","spanID":"dbe4222c262384c2"}],"startTime":1790716633530356,"duration":43,"tags":[{"key":"busy_ns","type":"int64","value":38316},{"key":"code.file.path","type":"string","value":"src/otel.rs"},{"key":"code.line.number","type":"int64","value":185},{"key":"code.module.name","type":"string","value":"image_api::otel"},{"key":"db.operation","type":"string","value":"get_job_by_id"},{"key":"db.query_type","type":"string","value":"query"},{"key":"idle_ns","type":"int64","value":5976},{"key":"otel.scope.name","type":"string","value":"image-server"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"internal"},{"key":"target","type":"string","value":"image_api::otel"},{"key":"thread.id","type":"int64","value":20},{"key":"thread.name","type":"string","value":"actix-rt|system:0|arbiter:4"}],"logs":[],"processID":"p1","warnings":null},{"traceID":"4e4c40ef185bcdc4bcf4dacbcd661891","spanID":"dbe4222c262384c2","operationName":"GET /insights/generation/status","references":[],"startTime":1790716633530283,"duration":152,"tags":[{"key":"busy_ns","type":"int64","value":110731},{"key":"code.file.path","type":"string","value":"/home/cameron/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tracing-actix-web-0.7.25/src/root_span_builder.rs"},{"key":"code.line.number","type":"int64","value":44},{"key":"code.module.name","type":"string","value":"tracing_actix_web::root_span_builder"},{"key":"http.client_ip","type":"string","value":"127.0.0.1"},{"key":"http.flavor","type":"string","value":"1.1"},{"key":"http.host","type":"string","value":"127.0.0.1:18080"},{"key":"http.method","type":"string","value":"GET"},{"key":"http.route","type":"string","value":"/insights/generation/status"},{"key":"http.scheme","type":"string","value":"http"},{"key":"http.status_code","type":"int64","value":200},{"key":"http.target","type":"string","value":"/insights/generation/status?job_id=380"},{"key":"http.user_agent","type":"string","value":"curl/8.5.0"},{"key":"idle_ns","type":"int64","value":42074},{"key":"otel.scope.name","type":"string","value":"image-server"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"request_id","type":"string","value":"7dbbfceb-000a-4bc0-9f1e-b9ef9612cea3"},{"key":"span.kind","type":"string","value":"server"},{"key":"target","type":"string","value":"tracing_actix_web::root_span_builder"},{"key":"thread.id","type":"int64","value":20},{"key":"thread.name","type":"string","value":"actix-rt|system:0|arbiter:4"},{"key":"trace_id","type":"string","value":"4e4c40ef185bcdc4bcf4dacbcd661891"}],"logs":[],"processID":"p1","warnings":null}],"processes":{"p1":{"serviceName":"image-server","tags":[{"key":"service.version","type":"string","value":"1.5.0"},{"key":"telemetry.sdk.language","type":"string","value":"rust"},{"key":"telemetry.sdk.name","type":"string","value":"opentelemetry"},{"key":"telemetry.sdk.version","type":"string","value":"0.31.0"}]}},"warnings":null},{"traceID":"9b6f5cad283c0ee75cfd796962a94047","spans":[{"traceID":"9b6f5cad283c0ee75cfd796962a94047","spanID":"f225af9372244bce","operationName":"db.query.get_job_by_id","references":[{"refType":"CHILD_OF","traceID":"9b6f5cad283c0ee75cfd796962a94047","spanID":"e33154be38ea4319"}],"startTime":1790716648578964,"duration":78,"tags":[{"key":"busy_ns","type":"int64","value":72519},{"key":"code.file.path","type":"string","value":"src/otel.rs"},{"key":"code.line.number","type":"int64","value":185},{"key":"code.module.name","type":"string","value":"image_api::otel"},{"key":"db.operation","type":"string","value":"get_job_by_id"},{"key":"db.query_type","type":"string","value":"query"},{"key":"idle_ns","type":"int64","value":7019},{"key":"otel.scope.name","type":"string","value":"image-server"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"internal"},{"key":"target","type":"string","value":"image_api::otel"},{"key":"thread.id","type":"int64","value":23},{"key":"thread.name","type":"string","value":"actix-rt|system:0|arbiter:7"}],"logs":[],"processID":"p1","warnings":null},{"traceID":"9b6f5cad283c0ee75cfd796962a94047","spanID":"e33154be38ea4319","operationName":"GET /insights/generation/status","references":[],"startTime":1790716648578868,"duration":219,"tags":[{"key":"busy_ns","type":"int64","value":157935},{"key":"code.file.path","type":"string","value":"/home/cameron/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tracing-actix-web-0.7.25/src/root_span_builder.rs"},{"key":"code.line.number","type":"int64","value":44},{"key":"code.module.name","type":"string","value":"tracing_actix_web::root_span_builder"},{"key":"http.client_ip","type":"string","value":"127.0.0.1"},{"key":"http.flavor","type":"string","valLine truncated
|
||||
@@ -0,0 +1,129 @@
|
||||
# Pre-migration span baseline (ticket #137, map #135)
|
||||
|
||||
Captured from **master @ `da3901c`** ("before" the tracing migration) so the
|
||||
post-migration output can be diffed for parity. See map #135 for the
|
||||
migration's destination.
|
||||
|
||||
## What was run
|
||||
|
||||
- **Revision:** `da3901c` (merge of "Prefs: owner defaults"), the last
|
||||
master commit before `feature/tracing-migration`.
|
||||
- **Build:** `cargo build --release` — release is required; the pre-migration
|
||||
OTel init is behind `#[cfg(not(debug_assertions))]`, so debug builds are a
|
||||
no-op (no global tracer provider, no spans).
|
||||
- **Exporter:** local Jaeger all-in-one (`jaegertracing/all-in-one:latest`),
|
||||
OTLP/gRPC on host `:14317` (container `:4317`), UI `:16686`. Prod SigNoz
|
||||
(`OTLP_OTLS_ENDPOINT=http://192.168.10.50:4317`) was overridden deliberately.
|
||||
- **DB:** a private `.backup` copy of the live `auth.db` (21,298 `image_exif`
|
||||
rows, 4 NAS-backed libraries). The live deployment (`image-rs.service`) was
|
||||
left running and untouched; all output dirs (thumbs/gifs/video/reels) point
|
||||
at a temp dir.
|
||||
- **LLM backend:** the live local stack — `LLM_BACKEND=llamacpp`,
|
||||
`LLAMA_SWAP_URL=http://192.168.10.50:9292/v1`, model
|
||||
`qwen3.8-27b-abliterated`.
|
||||
|
||||
### Deviation from pristine `da3901c`
|
||||
|
||||
The binary was built from `da3901c` with **one span-neutral change**: the
|
||||
hardcoded secondary listener `.bind("localhost:8088")` was moved to
|
||||
`.bind("localhost:18088")` because the live `image-rs.service` owns `:8088`.
|
||||
The listen port cannot affect span production. Everything else is unmodified.
|
||||
|
||||
### Requests exercised (the representative set)
|
||||
|
||||
All with `Authorization: Bearer <JWT>` (minted with the live `SECRET_KEY`,
|
||||
`sub=2`).
|
||||
|
||||
| # | Request | Span trace(s) |
|
||||
|---|---------|---------------|
|
||||
| 1 | `GET /photos?path=Colleen&recursive=true&limit=20` | `cb07eeec…` (3 spans) |
|
||||
| 2 | `GET /memories?path=.&recursive=true` | `44b3b67d…` (6 spans) |
|
||||
| 3 | `POST /insights/generate/agentic` `{file_path:"Photo06172345.jpg",backend:"local"}` → **202 job_id=380** (async) | `948d2756…` (HTTP span) **and** `9b520713…` (background, 22 spans) |
|
||||
| 4 | `POST /insights/chat/stream` `{file_path:"Photo06172345.jpg",user_message:"What was I wearing in this photo?",backend:"local"}` (SSE, 88 text deltas, `done`) | fragmented — see below |
|
||||
| 5 | `GET /photos?path=Colleen&recursive=true&limit=5` **with** `traceparent: 00-1111…1111-2222…2222-01` | `11111111…` (3 spans) |
|
||||
|
||||
## Span shape (the "before")
|
||||
|
||||
**Service:** `image-server` (resource `service.name`), version from
|
||||
`CARGO_PKG_VERSION`. No per-request HTTP server span exists — the migration's
|
||||
`tracing-actix-web` middleware (#139) adds one.
|
||||
|
||||
**Span names**
|
||||
|
||||
- Operation / "root" spans (created manually in handlers): `list_photos`,
|
||||
`list_memories`, `http.insights.generate_agentic`, `ai.insight.generate_agentic`,
|
||||
`ai.agentic.loop`, `ai.rag.search_daily_summaries`, `generate_video_thumbnail`.
|
||||
- DB spans: `db.{query_type}.{operation}` — `query_type ∈ {query, insert, update}`,
|
||||
`operation` = snake_case DAO method (`get_exif`, `store_insight`, …).
|
||||
Tracer scope (`otel.scope.name`) is `db`.
|
||||
- LLM spans: `llm.request`, tracer scope `image-server`.
|
||||
|
||||
**`span.kind`:** `internal` for every operation/DB span; `client` for
|
||||
`llm.request`.
|
||||
|
||||
**Key attributes**
|
||||
|
||||
- Every span: `otel.scope.name` (`image-server` or `db`), `otel.status_code`
|
||||
(`OK` / unset), `span.kind`.
|
||||
- DB (`db.*`): `db.query_type`, `db.operation`.
|
||||
- LLM (`llm.request`): `llm.model` (`qwen3.8-27b-abliterated`),
|
||||
`llm.provider` (`llamacpp`), `llm.operation` (`chat` for tool calls,
|
||||
`chat_stream` for the SSE turn), `llm.tokens.input`, `llm.tokens.output`,
|
||||
`http.status_code`.
|
||||
- `list_photos`: rich request shape — `path`, `recursive`, `sort`, `library`,
|
||||
`media_type`, `pagination.limit/offset/enabled`, `file_count`,
|
||||
`returned_count`, `total_count`, `optimization.batch_tags`,
|
||||
`optimization.db_sort`, `tag_filter_mode`, plus empty camera/gps/date fields.
|
||||
- `ai.insight.generate_agentic`: `backend`, `file_path`, `images_inline`,
|
||||
`max_iterations`, `model`. `ai.agentic.loop`: `iterations_used`.
|
||||
- `generate_video_thumbnail`: `file-name`, `library`, `type`.
|
||||
- `ai.rag.search_daily_summaries`: `query`, `date`, `limit`, `results_count`,
|
||||
`time_weighted`.
|
||||
|
||||
**Nesting — the headline finding.** Parent/child threading is *inconsistent*:
|
||||
|
||||
1. **No HTTP root span.** Handlers open an operation-named span as a root.
|
||||
2. **Most DB spans are orphaned into their own single-span trace** (see
|
||||
`span-inventory.json`: `trace_count == span_count` for most ops).
|
||||
`db.query.get_prefs`, `db.query.count_for_library`, `db.insert.store_insight`,
|
||||
`db.update.*`, etc. each form a separate trace.
|
||||
3. **Threading works in pockets.** `list_photos` (→ 2 DB children),
|
||||
`list_memories` (→ `get_prefs` + 4× `get_memories_in_window`), and the
|
||||
agentic loop (`ai.insight.generate_agentic` → `ai.agentic.loop` →
|
||||
`llm.request` + DB spans) nest correctly.
|
||||
4. **Async work loses the trace.** `POST /insights/generate/agentic` returns
|
||||
**202** with `http.insights.generate_agentic` in one trace; the background
|
||||
generation is a *different root trace* (`ai.insight.generate_agentic`).
|
||||
The streaming chat path is likewise fragmented — its DB calls and
|
||||
`llm.request` land as separate single-span traces, with **no handler span
|
||||
at all**.
|
||||
5. **One 222-span trace** (`b94440d1…`) holds the startup thumbnail sweep —
|
||||
all `generate_video_thumbnail` spans share one trace.
|
||||
|
||||
**Incoming propagation WORKS.** With a caller-supplied W3C `traceparent`, the
|
||||
`list_photos` span is created in the caller's trace-id and references the
|
||||
remote span-id as `CHILD_OF` — i.e. the request joins the upstream trace
|
||||
correctly. This is the behavior the migration must preserve.
|
||||
|
||||
## Files
|
||||
|
||||
- `traces-no-traceparent.json` — all 33 traces from requests 1–4 (Jaeger
|
||||
`/api/traces` dump; requests made without an incoming traceparent).
|
||||
- `traces-with-traceparent.json` — includes the propagation test (request 5).
|
||||
- `span-inventory.json` — per-operation summary: span count, trace count,
|
||||
kind, scope, status, attribute keys. The direct input for a before/after
|
||||
shape diff.
|
||||
|
||||
## Re-capturing
|
||||
|
||||
1. Build `da3901c` release (or the branch under test for the "after").
|
||||
2. `docker run -d --name jaeger -p 16686:16686 -p 14317:4317 jaegertracing/all-in-one:latest`.
|
||||
3. Run the server against a **copy** of the DB with
|
||||
`OTLP_OTLS_ENDPOINT=http://localhost:14317` and a temp `BIND_URL`; remember
|
||||
the hardcoded secondary `localhost:8088` bind collides with the live
|
||||
service (patch it, or stop the service).
|
||||
4. Mint a JWT (`HS256`, `{sub, exp}`, `SECRET_KEY`) and replay the request set
|
||||
in the table above.
|
||||
5. `curl 'http://localhost:16686/api/traces?service=image-server&limit=600'`
|
||||
→ regenerate `span-inventory.json` and diff operation names, kinds,
|
||||
attribute keys, and nesting.
|
||||
@@ -0,0 +1,590 @@
|
||||
[
|
||||
{
|
||||
"operation": "generate_video_thumbnail",
|
||||
"span_count": 222,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "(none)",
|
||||
"attribute_keys": [
|
||||
"file-name",
|
||||
"library",
|
||||
"otel.scope.name",
|
||||
"span.kind",
|
||||
"type"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "llm.request",
|
||||
"span_count": 8,
|
||||
"trace_count": 3,
|
||||
"kind": "client",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"http.status_code",
|
||||
"llm.model",
|
||||
"llm.operation",
|
||||
"llm.provider",
|
||||
"llm.tokens.input",
|
||||
"llm.tokens.output",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.query.count_for_library",
|
||||
"span_count": 4,
|
||||
"trace_count": 4,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_memories_in_window",
|
||||
"span_count": 4,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_persona",
|
||||
"span_count": 4,
|
||||
"trace_count": 4,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_prefs",
|
||||
"span_count": 4,
|
||||
"trace_count": 4,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.insert.upsert_entity",
|
||||
"span_count": 2,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_event_count",
|
||||
"span_count": 2,
|
||||
"trace_count": 2,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_exif",
|
||||
"span_count": 2,
|
||||
"trace_count": 2,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_location_count",
|
||||
"span_count": 2,
|
||||
"trace_count": 2,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.query.has_any_faces",
|
||||
"span_count": 2,
|
||||
"trace_count": 2,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.query.has_any_summaries",
|
||||
"span_count": 2,
|
||||
"trace_count": 2,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.query.list_entities",
|
||||
"span_count": 2,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "ai.agentic.loop",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"iterations_used",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "ai.insight.generate_agentic",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"backend",
|
||||
"file_path",
|
||||
"images_inline",
|
||||
"max_iterations",
|
||||
"model",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "ai.rag.search_daily_summaries",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"date",
|
||||
"limit",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"query",
|
||||
"results_count",
|
||||
"span.kind",
|
||||
"time_weighted"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.insert.create_job",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.insert.store_insight",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.insert.upsert_fact",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.insert.upsert_photo_link",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.query.faces_for_hash",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"content_hash",
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.query.find_relevant_events_hybrid",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.query.find_similar_summaries_with_time_weight",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_active_job",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_current_insight_for_library",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_links_for_photo",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_tag_counts_batch",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"file_count",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.query.get_tags_for_path",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"path",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.query.list_rel_paths_for_libraries",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.query.resolve_content_hash",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.update.cancel_active_jobs",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.update.complete_job",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.update.update_facts_insight_id",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "db.update.update_training_messages",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "db",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"db.operation",
|
||||
"db.query_type",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "http.insights.generate_agentic",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"backend",
|
||||
"file_path",
|
||||
"job_id",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "list_memories",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"span.kind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"operation": "list_photos",
|
||||
"span_count": 1,
|
||||
"trace_count": 1,
|
||||
"kind": "internal",
|
||||
"scope": "image-server",
|
||||
"status": "OK",
|
||||
"attribute_keys": [
|
||||
"camera_make",
|
||||
"camera_model",
|
||||
"date_from",
|
||||
"date_to",
|
||||
"exclude_tag_ids",
|
||||
"file_count",
|
||||
"gps_lat",
|
||||
"gps_lon",
|
||||
"gps_radius_km",
|
||||
"lens_model",
|
||||
"library",
|
||||
"media_type",
|
||||
"optimization.batch_tags",
|
||||
"optimization.db_sort",
|
||||
"otel.scope.name",
|
||||
"otel.status_code",
|
||||
"pagination.enabled",
|
||||
"pagination.limit",
|
||||
"pagination.offset",
|
||||
"path",
|
||||
"recursive",
|
||||
"returned_count",
|
||||
"sort",
|
||||
"span.kind",
|
||||
"tag_filter_mode",
|
||||
"tag_ids",
|
||||
"total_count"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
{"data":[{"traceID":"08455e5e41fe244713bdbce55178fb74","spans":[{"traceID":"08455e5e41fe244713bdbce55178fb74","spanID":"53b66e719b4da88b","operationName":"db.query.get_persona","references":[],"startTime":1790704286497358,"duration":97,"tags":[{"key":"db.operation","type":"string","value":"get_persona"},{"key":"db.query_type","type":"string","value":"query"},{"key":"otel.scope.name","type":"string","value":"db"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"internal"}],"logs":[],"processID":"p1","warnings":null}],"processes":{"p1":{"serviceName":"image-server","tags":[{"key":"service.version","type":"string","value":"1.5.0"},{"key":"telemetry.sdk.language","type":"string","value":"rust"},{"key":"telemetry.sdk.name","type":"string","value":"opentelemetry"},{"key":"telemetry.sdk.version","type":"string","value":"0.31.0"}]}},"warnings":null},{"traceID":"33dd886ca35f036dc130e157d18cde88","spans":[{"traceID":"33dd886ca35f036dc130e157d18cde88","spanID":"b9886203d0340833","operationName":"db.insert.store_insight","references":[],"startTime":1790704373774193,"duration":6036,"tags":[{"key":"db.operation","type":"string","value":"store_insight"},{"key":"db.query_type","type":"string","value":"insert"},{"key":"otel.scope.name","type":"string","value":"db"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"internal"}],"logs":[],"processID":"p1","warnings":null}],"processes":{"p1":{"serviceName":"image-server","tags":[{"key":"service.version","type":"string","value":"1.5.0"},{"key":"telemetry.sdk.language","type":"string","value":"rust"},{"key":"telemetry.sdk.name","type":"string","value":"opentelemetry"},{"key":"telemetry.sdk.version","type":"string","value":"0.31.0"}]}},"warnings":null},{"traceID":"6a3205b66ce3f4f25e849804437b4d2a","spans":[{"traceID":"6a3205b66ce3f4f25e849804437b4d2a","spanID":"689b895b8779feee","operationName":"db.query.get_event_count","references":[],"startTime":1790704423171303,"duration":458,"tags":[{"key":"db.operation","type":"string","value":"get_event_count"},{"key":"db.query_type","type":"string","value":"query"},{"key":"otel.scope.name","type":"string","value":"db"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"internal"}],"logs":[],"processID":"p1","warnings":null}],"processes":{"p1":{"serviceName":"image-server","tags":[{"key":"service.version","type":"string","value":"1.5.0"},{"key":"telemetry.sdk.language","type":"string","value":"rust"},{"key":"telemetry.sdk.name","type":"string","value":"opentelemetry"},{"key":"telemetry.sdk.version","type":"string","value":"0.31.0"}]}},"warnings":null},{"traceID":"9f649ce92d6b31b1e85900a052388fd9","spans":[{"traceID":"9f649ce92d6b31b1e85900a052388fd9","spanID":"36e0c7e5a2ee06b0","operationName":"llm.request","references":[],"startTime":1790704423320965,"duration":31634307,"tags":[{"key":"http.status_code","type":"int64","value":200},{"key":"llm.model","type":"string","value":"qwen3.8-27b-abliterated"},{"key":"llm.operation","type":"string","value":"chat_stream"},{"key":"llm.provider","type":"string","value":"llamacpp"},{"key":"llm.tokens.input","type":"int64","value":6904},{"key":"llm.tokens.output","type":"int64","value":1422},{"key":"otel.scope.name","type":"string","value":"image-server"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"client"}],"logs":[],"processID":"p1","warnings":null}],"processes":{"p1":{"serviceName":"image-server","tags":[{"key":"service.version","type":"string","value":"1.5.0"},{"key":"telemetry.sdk.language","type":"string","value":"rust"},{"key":"telemetry.sdk.name","type":"string","value":"opentelemetry"},{"key":"telemetry.sdk.version","type":"string","value":"0.31.0"}]}},"warnings":null},{"traceID":"b0339bf5be5b7ca90eded50c4c30ea8d","spans":[{"traceID":"b0339bf5be5b7ca90eded50c4c30ea8d","spanID":"4db108684f58fcaa","operationName":"db.update.update_training_messages","references":[],"startTime":1790704454955816,"duration":1631,"tags":[{"key":"db.operation","type":"string","value":"update_training_messages"},{"key":"db.query_type","type":"string","value":"update"},{"key":"otel.scope.name","type":"string","value":"db"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"internal"}],"logs":[],"processID":"p1","warnings":null}],"processes":{"p1":{"serviceName":"image-server","tags":[{"key":"service.version","type":"string","value":"1.5.0"},{"key":"telemetry.sdk.language","type":"string","value":"rust"},{"key":"telemetry.sdk.name","type":"string","value":"opentelemetry"},{"key":"telemetry.sdk.version","type":"string","value":"0.31.0"}]}},"warnings":null},{"traceID":"e710521acc2fac9cf41374cb56cc2e8c","spans":[{"traceID":"e710521acc2fac9cf41374cb56cc2e8c","spanID":"29772615a01901bf","operationName":"db.query.count_for_library","references":[],"startTime":1790704268693001,"Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"data":[{"traceID":"9e81fc7e14eaf9f372040183caf7b093","spans":[{"traceID":"9e81fc7e14eaf9f372040183caf7b093","spanID":"2a3fd729253fcef9","operationName":"db.query.count_for_library","references":[],"startTime":1790704268692143,"duration":617,"tags":[{"key":"db.operation","type":"string","value":"count_for_library"},{"key":"db.query_type","type":"string","value":"query"},{"key":"otel.scope.name","type":"string","value":"db"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"internal"}],"logs":[],"processID":"p1","warnings":null}],"processes":{"p1":{"serviceName":"image-server","tags":[{"key":"service.version","type":"string","value":"1.5.0"},{"key":"telemetry.sdk.language","type":"string","value":"rust"},{"key":"telemetry.sdk.name","type":"string","value":"opentelemetry"},{"key":"telemetry.sdk.version","type":"string","value":"0.31.0"}]}},"warnings":null},{"traceID":"948d27569252087184047aafb327e9f2","spans":[{"traceID":"948d27569252087184047aafb327e9f2","spanID":"18359fc0dd6057b2","operationName":"http.insights.generate_agentic","references":[],"startTime":1790704286474704,"duration":22781,"tags":[{"key":"backend","type":"string","value":"local"},{"key":"file_path","type":"string","value":"Photo06172345.jpg"},{"key":"job_id","type":"int64","value":380},{"key":"otel.scope.name","type":"string","value":"image-server"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"internal"}],"logs":[],"processID":"p1","warnings":null}],"processes":{"p1":{"serviceName":"image-server","tags":[{"key":"service.version","type":"string","value":"1.5.0"},{"key":"telemetry.sdk.language","type":"string","value":"rust"},{"key":"telemetry.sdk.name","type":"string","value":"opentelemetry"},{"key":"telemetry.sdk.version","type":"string","value":"0.31.0"}]}},"warnings":null},{"traceID":"1a5ac0cb51a80bdaf47f9e48c6540eca","spans":[{"traceID":"1a5ac0cb51a80bdaf47f9e48c6540eca","spanID":"43600617e64593a2","operationName":"db.query.get_prefs","references":[],"startTime":1790704286497889,"duration":1098,"tags":[{"key":"db.operation","type":"string","value":"get_prefs"},{"key":"db.query_type","type":"string","value":"query"},{"key":"otel.scope.name","type":"string","value":"db"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"internal"}],"logs":[],"processID":"p1","warnings":null}],"processes":{"p1":{"serviceName":"image-server","tags":[{"key":"service.version","type":"string","value":"1.5.0"},{"key":"telemetry.sdk.language","type":"string","value":"rust"},{"key":"telemetry.sdk.name","type":"string","value":"opentelemetry"},{"key":"telemetry.sdk.version","type":"string","value":"0.31.0"}]}},"warnings":null},{"traceID":"0af6272e8e1e6ded88403fe2761b552c","spans":[{"traceID":"0af6272e8e1e6ded88403fe2761b552c","spanID":"c2ec516d23e8a6df","operationName":"db.query.count_for_library","references":[],"startTime":1790704268693775,"duration":287,"tags":[{"key":"db.operation","type":"string","value":"count_for_library"},{"key":"db.query_type","type":"string","value":"query"},{"key":"otel.scope.name","type":"string","value":"db"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"internal"}],"logs":[],"processID":"p1","warnings":null}],"processes":{"p1":{"serviceName":"image-server","tags":[{"key":"service.version","type":"string","value":"1.5.0"},{"key":"telemetry.sdk.language","type":"string","value":"rust"},{"key":"telemetry.sdk.name","type":"string","value":"opentelemetry"},{"key":"telemetry.sdk.version","type":"string","value":"0.31.0"}]}},"warnings":null},{"traceID":"27cefad7c25390286c6b3fc43c9cd9c7","spans":[{"traceID":"27cefad7c25390286c6b3fc43c9cd9c7","spanID":"eb323a2e3d166157","operationName":"db.query.get_event_count","references":[],"startTime":1790704286604710,"duration":57,"tags":[{"key":"db.operation","type":"string","value":"get_event_count"},{"key":"db.query_type","type":"string","value":"query"},{"key":"otel.scope.name","type":"string","value":"db"},{"key":"otel.status_code","type":"string","value":"OK"},{"key":"span.kind","type":"string","value":"internal"}],"logs":[],"processID":"p1","warnings":null}],"processes":{"p1":{"serviceName":"image-server","tags":[{"key":"service.version","type":"string","value":"1.5.0"},{"key":"telemetry.sdk.language","type":"string","value":"rust"},{"key":"telemetry.sdk.name","type":"string","value":"opentelemetry"},{"key":"telemetry.sdk.version","type":"string","value":"0.31.0"}]}},"warnings":null},{"traceID":"957362713b0580085adc63b7574a4451","spans":[{"traceID":"957362713b0580085adc63b7574a4451","spanID":"2199d1950cac212a","operationName":"db.query.get_location_count","references":[],"startTime":1790704286604771,"duration":4702,"tags":[{"key":"db.operation","type":"string","value":"get_location_count"},{"key":"db.query_type","type":"string","value":"query"},{"key":"otel.scope.name","type":"string","valuLine truncated
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE user_ai_prefs DROP COLUMN default_model;
|
||||
ALTER TABLE user_ai_prefs DROP COLUMN default_backend;
|
||||
ALTER TABLE user_ai_prefs DROP COLUMN default_persona_id;
|
||||
ALTER TABLE user_ai_prefs DROP COLUMN reel_span;
|
||||
ALTER TABLE user_ai_prefs DROP COLUMN reel_max_segments;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Owner defaults: recurring per-user picks that were previously re-sent on
|
||||
-- every request. All nullable — NULL = keep the current per-request /
|
||||
-- hard-default behavior.
|
||||
ALTER TABLE user_ai_prefs ADD COLUMN default_model TEXT;
|
||||
ALTER TABLE user_ai_prefs ADD COLUMN default_backend TEXT;
|
||||
ALTER TABLE user_ai_prefs ADD COLUMN default_persona_id TEXT;
|
||||
ALTER TABLE user_ai_prefs ADD COLUMN reel_span TEXT;
|
||||
ALTER TABLE user_ai_prefs ADD COLUMN reel_max_segments INTEGER;
|
||||
@@ -360,11 +360,17 @@ async fn generate_and_store_daily_summary(
|
||||
span.set_attribute(KeyValue::new("date", date.to_string()));
|
||||
span.set_attribute(KeyValue::new("contact", contact.to_string()));
|
||||
span.set_attribute(KeyValue::new("message_count", messages.len() as i64));
|
||||
// Parent context for the child llm.request spans; the span itself stays
|
||||
// borrowed here for the attribute updates below.
|
||||
let llm_cx =
|
||||
opentelemetry::Context::new().with_remote_span_context(span.span_context().clone());
|
||||
|
||||
let (prompt, system_prompt) = build_daily_summary_prompt(contact, date, messages);
|
||||
|
||||
// Generate summary with LLM
|
||||
let summary = ollama.generate(&prompt, Some(system_prompt)).await?;
|
||||
let summary = ollama
|
||||
.generate(&llm_cx, &prompt, Some(system_prompt))
|
||||
.await?;
|
||||
|
||||
log::debug!(
|
||||
"Generated summary for {}: {}",
|
||||
@@ -382,7 +388,9 @@ async fn generate_and_store_daily_summary(
|
||||
);
|
||||
|
||||
// Embed the stripped summary (store original summary in DB)
|
||||
let embedding = ollama.generate_embedding(&stripped_summary).await?;
|
||||
let embedding = ollama
|
||||
.generate_embedding(&llm_cx, &stripped_summary)
|
||||
.await?;
|
||||
|
||||
span.set_attribute(KeyValue::new(
|
||||
"embedding_dimensions",
|
||||
|
||||
+175
-1
@@ -384,6 +384,36 @@ pub struct ServerModels {
|
||||
pub default_model: String,
|
||||
}
|
||||
|
||||
/// Apply the stored owner defaults to any request field the client omitted.
|
||||
/// Resolution order: request param → stored pref → hard default (applied
|
||||
/// downstream). The request always wins; the pref only fills gaps. One DAO
|
||||
/// read per request; a failed read silently falls back to hard defaults.
|
||||
fn apply_owner_defaults(
|
||||
app_state: &AppState,
|
||||
model: &mut Option<String>,
|
||||
backend: &mut Option<String>,
|
||||
persona_id: &mut Option<String>,
|
||||
) {
|
||||
if model.is_some() && backend.is_some() && persona_id.is_some() {
|
||||
return; // nothing to fill — skip the DB read
|
||||
}
|
||||
let Some(mut dao) = app_state.user_ai_prefs_dao.lock().ok() else {
|
||||
return;
|
||||
};
|
||||
let Ok(Some(prefs)) = dao.get_prefs(&opentelemetry::Context::new()) else {
|
||||
return;
|
||||
};
|
||||
if model.is_none() {
|
||||
*model = prefs.default_model;
|
||||
}
|
||||
if backend.is_none() {
|
||||
*backend = prefs.default_backend;
|
||||
}
|
||||
if persona_id.is_none() {
|
||||
*persona_id = prefs.default_persona_id;
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /insights/generate - Generate insight for a specific photo (async)
|
||||
#[post("/insights/generate")]
|
||||
pub async fn generate_insight_handler(
|
||||
@@ -396,6 +426,16 @@ pub async fn generate_insight_handler(
|
||||
let tracer = global_tracer();
|
||||
let mut span = tracer.start_with_context("http.insights.generate", &parent_context);
|
||||
|
||||
let mut request = request.0;
|
||||
// Stored owner defaults fill what the client omitted (request → pref →
|
||||
// hard default; the hard default still applies downstream).
|
||||
apply_owner_defaults(
|
||||
&app_state,
|
||||
&mut request.model,
|
||||
&mut request.backend,
|
||||
&mut request.persona_id,
|
||||
);
|
||||
|
||||
let normalized_path = normalize_path(&request.file_path);
|
||||
let library = app_state.primary_library();
|
||||
let gen_type = InsightGenerationType::Standard;
|
||||
@@ -714,6 +754,16 @@ pub async fn generate_agentic_insight_handler(
|
||||
let tracer = global_tracer();
|
||||
let mut span = tracer.start_with_context("http.insights.generate_agentic", &parent_context);
|
||||
|
||||
let mut request = request.0;
|
||||
// Stored owner defaults fill what the client omitted (request → pref →
|
||||
// hard default; the hard default still applies downstream).
|
||||
apply_owner_defaults(
|
||||
&app_state,
|
||||
&mut request.model,
|
||||
&mut request.backend,
|
||||
&mut request.persona_id,
|
||||
);
|
||||
|
||||
let normalized_path = normalize_path(&request.file_path);
|
||||
let library = app_state.primary_library();
|
||||
let gen_type = InsightGenerationType::Agentic;
|
||||
@@ -1231,6 +1281,16 @@ pub async fn chat_turn_handler(
|
||||
let mut span = tracer.start_with_context("http.insights.chat", &parent_context);
|
||||
span.set_attribute(KeyValue::new("file_path", request.file_path.clone()));
|
||||
|
||||
let mut request = request.0;
|
||||
// Stored owner defaults fill what the client omitted (request → pref →
|
||||
// hard default; the hard default still applies downstream).
|
||||
apply_owner_defaults(
|
||||
&app_state,
|
||||
&mut request.model,
|
||||
&mut request.backend,
|
||||
&mut request.persona_id,
|
||||
);
|
||||
|
||||
let library =
|
||||
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
|
||||
Ok(Some(lib)) => lib,
|
||||
@@ -1607,10 +1667,22 @@ pub struct ChatSwitchBranchRequest {
|
||||
/// Returns `text/event-stream` with one event per chat stream event.
|
||||
#[post("/insights/chat/stream")]
|
||||
pub async fn chat_stream_handler(
|
||||
http_request: HttpRequest,
|
||||
claims: Claims,
|
||||
request: web::Json<ChatTurnHttpRequest>,
|
||||
app_state: web::Data<AppState>,
|
||||
) -> HttpResponse {
|
||||
let parent_context = extract_context_from_request(&http_request);
|
||||
let mut request = request.0;
|
||||
// Stored owner defaults fill what the client omitted (request → pref →
|
||||
// hard default; the hard default still applies downstream).
|
||||
apply_owner_defaults(
|
||||
&app_state,
|
||||
&mut request.model,
|
||||
&mut request.backend,
|
||||
&mut request.persona_id,
|
||||
);
|
||||
|
||||
let library =
|
||||
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
|
||||
Ok(Some(lib)) => lib,
|
||||
@@ -1646,7 +1718,7 @@ pub async fn chat_stream_handler(
|
||||
};
|
||||
|
||||
let service = app_state.insight_chat.clone();
|
||||
let events = service.chat_turn_stream(chat_req);
|
||||
let events = service.chat_turn_stream(chat_req, parent_context);
|
||||
|
||||
// Map ChatStreamEvent → SSE frame bytes.
|
||||
let sse_stream = futures::stream::StreamExt::map(events, |ev| {
|
||||
@@ -1758,6 +1830,16 @@ pub async fn turn_async_handler(
|
||||
let mut span = tracer.start_with_context("http.insights.chat_turn_async", &parent_context);
|
||||
span.set_attribute(KeyValue::new("file_path", request.file_path.clone()));
|
||||
|
||||
let mut request = request.0;
|
||||
// Stored owner defaults fill what the client omitted (request → pref →
|
||||
// hard default; the hard default still applies downstream).
|
||||
apply_owner_defaults(
|
||||
&app_state,
|
||||
&mut request.model,
|
||||
&mut request.backend,
|
||||
&mut request.persona_id,
|
||||
);
|
||||
|
||||
let library =
|
||||
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
|
||||
Ok(Some(lib)) => lib,
|
||||
@@ -2277,3 +2359,95 @@ mod turn_replay_tests {
|
||||
assert_eq!(info.total_events_pushed, 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod owner_defaults_tests {
|
||||
use super::apply_owner_defaults;
|
||||
use crate::database::models::UpsertUserAiPrefs;
|
||||
use crate::database::{SqliteUserAiPrefsDao, UserAiPrefsDao};
|
||||
use crate::state::AppState;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn prefs_state(prefs: Option<UpsertUserAiPrefs>) -> AppState {
|
||||
let conn = Arc::new(Mutex::new(crate::database::test::in_memory_db_connection()));
|
||||
let mut state = AppState::test_state();
|
||||
state.user_ai_prefs_dao = Arc::new(Mutex::new(Box::new(
|
||||
SqliteUserAiPrefsDao::from_connection(conn.clone()),
|
||||
)));
|
||||
if let Some(p) = prefs {
|
||||
let mut dao = SqliteUserAiPrefsDao::from_connection(conn);
|
||||
dao.upsert_prefs(&opentelemetry::Context::new(), &p)
|
||||
.expect("seed prefs");
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
fn seeded_defaults() -> UpsertUserAiPrefs {
|
||||
UpsertUserAiPrefs {
|
||||
voice: None,
|
||||
tz_offset_minutes: None,
|
||||
library: None,
|
||||
updated_at: 0,
|
||||
display_tz: None,
|
||||
default_model: Some("llama3.2:3b".into()),
|
||||
default_backend: Some("hybrid".into()),
|
||||
default_persona_id: Some("cabin".into()),
|
||||
reel_span: None,
|
||||
reel_max_segments: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn request_wins_over_stored_pref() {
|
||||
let state = prefs_state(Some(seeded_defaults()));
|
||||
let mut model = Some("explicit".to_string());
|
||||
let mut backend = Some("local".to_string());
|
||||
let mut persona = Some("explicit-p".to_string());
|
||||
apply_owner_defaults(&state, &mut model, &mut backend, &mut persona);
|
||||
assert_eq!(model.as_deref(), Some("explicit"));
|
||||
assert_eq!(backend.as_deref(), Some("local"));
|
||||
assert_eq!(persona.as_deref(), Some("explicit-p"));
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn pref_fills_omitted_fields() {
|
||||
let state = prefs_state(Some(seeded_defaults()));
|
||||
let mut model = None;
|
||||
let mut backend = None;
|
||||
let mut persona = None;
|
||||
apply_owner_defaults(&state, &mut model, &mut backend, &mut persona);
|
||||
assert_eq!(model.as_deref(), Some("llama3.2:3b"));
|
||||
assert_eq!(backend.as_deref(), Some("hybrid"));
|
||||
assert_eq!(persona.as_deref(), Some("cabin"));
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn no_pref_row_leaves_fields_none() {
|
||||
let state = prefs_state(None);
|
||||
let mut model = None;
|
||||
let mut backend = None;
|
||||
let mut persona = None;
|
||||
apply_owner_defaults(&state, &mut model, &mut backend, &mut persona);
|
||||
// Hard defaults apply downstream; the pref layer fills nothing.
|
||||
assert!(model.is_none());
|
||||
assert!(backend.is_none());
|
||||
assert!(persona.is_none());
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn pref_fills_only_the_fields_it_has() {
|
||||
let state = prefs_state(Some(UpsertUserAiPrefs {
|
||||
default_backend: None,
|
||||
default_persona_id: Some("journal".into()),
|
||||
..seeded_defaults()
|
||||
}));
|
||||
let mut model = None;
|
||||
let mut backend = Some("local".to_string());
|
||||
let mut persona = None;
|
||||
apply_owner_defaults(&state, &mut model, &mut backend, &mut persona);
|
||||
assert_eq!(model.as_deref(), Some("llama3.2:3b"));
|
||||
// Explicit request backend is untouched; pref had none anyway.
|
||||
assert_eq!(backend.as_deref(), Some("local"));
|
||||
assert_eq!(persona.as_deref(), Some("journal"));
|
||||
}
|
||||
}
|
||||
+230
-34
@@ -1,5 +1,5 @@
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use chrono::Utc;
|
||||
use chrono::{TimeZone, Utc};
|
||||
use opentelemetry::KeyValue;
|
||||
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
|
||||
use std::collections::HashMap;
|
||||
@@ -377,7 +377,7 @@ impl InsightChatService {
|
||||
|
||||
let (response, prompt_tokens, eval_tokens) = backend
|
||||
.chat()
|
||||
.chat_with_tools(messages.clone(), tools.clone())
|
||||
.chat_with_tools(&loop_cx, messages.clone(), tools.clone())
|
||||
.await?;
|
||||
last_prompt_eval_count = prompt_tokens;
|
||||
last_eval_count = eval_tokens;
|
||||
@@ -434,7 +434,7 @@ impl InsightChatService {
|
||||
let synthetic_idx = push_synthetic_final_prompt(&mut messages);
|
||||
let (final_response, prompt_tokens, eval_tokens) = backend
|
||||
.chat()
|
||||
.chat_with_tools(messages.clone(), vec![])
|
||||
.chat_with_tools(&loop_cx, messages.clone(), vec![])
|
||||
.await?;
|
||||
last_prompt_eval_count = prompt_tokens;
|
||||
last_eval_count = eval_tokens;
|
||||
@@ -492,6 +492,7 @@ impl InsightChatService {
|
||||
let title_raw = backend
|
||||
.chat()
|
||||
.generate(
|
||||
&loop_cx,
|
||||
&title_prompt,
|
||||
Some(
|
||||
"You are my long term memory assistant. Use only the information provided. Do not invent details.",
|
||||
@@ -781,10 +782,11 @@ impl InsightChatService {
|
||||
pub fn chat_turn_stream(
|
||||
self: Arc<Self>,
|
||||
req: ChatTurnRequest,
|
||||
cx: opentelemetry::Context,
|
||||
) -> BoxStream<'static, ChatStreamEvent> {
|
||||
let svc = self;
|
||||
let s = async_stream::stream! {
|
||||
match svc.chat_turn_stream_inner(req, Ok).await {
|
||||
match svc.chat_turn_stream_inner(req, cx, Ok).await {
|
||||
Ok(mut rx) => {
|
||||
while let Some(ev) = rx.recv().await {
|
||||
yield ev;
|
||||
@@ -806,6 +808,7 @@ impl InsightChatService {
|
||||
async fn chat_turn_stream_inner<F>(
|
||||
self: Arc<Self>,
|
||||
req: ChatTurnRequest,
|
||||
cx: opentelemetry::Context,
|
||||
_ev_mapper: F,
|
||||
) -> Result<tokio::sync::mpsc::Receiver<ChatStreamEvent>>
|
||||
where
|
||||
@@ -814,7 +817,7 @@ impl InsightChatService {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<ChatStreamEvent>(64);
|
||||
let svc = self.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = svc.run_streaming_turn(req, tx.clone()).await;
|
||||
let result = svc.run_streaming_turn(req, cx, tx.clone()).await;
|
||||
if let Err(e) = result {
|
||||
let _ = tx.send(ChatStreamEvent::Error(format!("{}", e))).await;
|
||||
}
|
||||
@@ -852,7 +855,7 @@ impl InsightChatService {
|
||||
span.set_attribute(KeyValue::new("library_id", library_id as i64));
|
||||
|
||||
let result = svc
|
||||
.run_streaming_turn_with_entry(req, entry_clone.clone())
|
||||
.run_streaming_turn_with_entry(req, entry_clone.clone(), &mut span)
|
||||
.await;
|
||||
if let Err(ref e) = result {
|
||||
span.set_attribute(KeyValue::new("status", "error"));
|
||||
@@ -882,6 +885,7 @@ impl InsightChatService {
|
||||
self: Arc<Self>,
|
||||
req: ChatTurnRequest,
|
||||
entry: Arc<TurnEntry>,
|
||||
turn_span: &mut opentelemetry::global::BoxedSpan,
|
||||
) -> Result<()> {
|
||||
if req.user_message.trim().is_empty() {
|
||||
bail!("user_message must not be empty");
|
||||
@@ -911,11 +915,11 @@ impl InsightChatService {
|
||||
|
||||
if req.regenerate || existing_insight.is_none() {
|
||||
return self
|
||||
.run_bootstrap_streaming_with_entry(req, normalized, entry)
|
||||
.run_bootstrap_streaming_with_entry(req, normalized, entry, turn_span)
|
||||
.await;
|
||||
}
|
||||
let insight = existing_insight.expect("just checked Some above");
|
||||
self.run_continuation_streaming_with_entry(req, normalized, insight, entry)
|
||||
self.run_continuation_streaming_with_entry(req, normalized, insight, entry, turn_span)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -926,6 +930,7 @@ impl InsightChatService {
|
||||
normalized: String,
|
||||
insight: crate::database::models::PhotoInsight,
|
||||
entry: Arc<TurnEntry>,
|
||||
turn_span: &mut opentelemetry::global::BoxedSpan,
|
||||
) -> Result<()> {
|
||||
let active_persona = req
|
||||
.persona_id
|
||||
@@ -981,6 +986,12 @@ impl InsightChatService {
|
||||
};
|
||||
let backend = self.generator.resolve_backend(kind, &overrides).await?;
|
||||
let model_used = backend.model().to_string();
|
||||
// llm.* attributes on the turn-level span (issue #130): clients can't
|
||||
// know local-vs-hybrid, but the turn knows its resolved backend.
|
||||
turn_span.set_attribute(KeyValue::new("llm.backend", kind.as_str()));
|
||||
turn_span.set_attribute(KeyValue::new("llm.model", model_used.clone()));
|
||||
let turn_cx = opentelemetry::Context::new()
|
||||
.with_remote_span_context(turn_span.span_context().clone());
|
||||
|
||||
let local_first_user_has_image = messages
|
||||
.iter()
|
||||
@@ -1030,6 +1041,7 @@ impl InsightChatService {
|
||||
&active_persona,
|
||||
max_iterations,
|
||||
&entry,
|
||||
&turn_cx,
|
||||
)
|
||||
.await?;
|
||||
let AgenticLoopOutcome {
|
||||
@@ -1146,6 +1158,7 @@ impl InsightChatService {
|
||||
req: ChatTurnRequest,
|
||||
normalized: String,
|
||||
entry: Arc<TurnEntry>,
|
||||
turn_span: &mut opentelemetry::global::BoxedSpan,
|
||||
) -> Result<()> {
|
||||
let active_persona = req
|
||||
.persona_id
|
||||
@@ -1171,11 +1184,21 @@ impl InsightChatService {
|
||||
};
|
||||
let backend = self.generator.resolve_backend(kind, &overrides).await?;
|
||||
let model_used = backend.model().to_string();
|
||||
// llm.* attributes on the turn-level span (issue #130).
|
||||
turn_span.set_attribute(KeyValue::new("llm.backend", kind.as_str()));
|
||||
turn_span.set_attribute(KeyValue::new("llm.model", model_used.clone()));
|
||||
let turn_cx = opentelemetry::Context::new()
|
||||
.with_remote_span_context(turn_span.span_context().clone());
|
||||
|
||||
let image_base64: Option<String> = self.generator.load_image_as_base64(&normalized).ok();
|
||||
|
||||
let exif = self.generator.fetch_exif(&normalized);
|
||||
let date_taken_str = resolve_date_taken_for_context(&exif, &normalized);
|
||||
let date_taken_str = resolve_date_taken_for_context(
|
||||
&exif,
|
||||
&normalized,
|
||||
self.generator.display_zone(),
|
||||
crate::camera_tz::server_zone(),
|
||||
);
|
||||
let gps = exif
|
||||
.as_ref()
|
||||
.and_then(|e| match (e.gps_latitude, e.gps_longitude) {
|
||||
@@ -1185,7 +1208,7 @@ impl InsightChatService {
|
||||
|
||||
let visual_block = if !backend.images_inline {
|
||||
match image_base64.as_deref() {
|
||||
Some(b64) => match backend.local().describe_image(b64).await {
|
||||
Some(b64) => match backend.local().describe_image(&turn_cx, b64).await {
|
||||
Ok(desc) => {
|
||||
format!("Visual description (from local vision model):\n{}\n", desc)
|
||||
}
|
||||
@@ -1240,6 +1263,7 @@ impl InsightChatService {
|
||||
&active_persona,
|
||||
max_iterations,
|
||||
&entry,
|
||||
&turn_cx,
|
||||
)
|
||||
.await?;
|
||||
let AgenticLoopOutcome {
|
||||
@@ -1328,6 +1352,7 @@ impl InsightChatService {
|
||||
active_persona: &str,
|
||||
max_iterations: usize,
|
||||
entry: &Arc<TurnEntry>,
|
||||
cx: &opentelemetry::Context,
|
||||
) -> Result<AgenticLoopOutcome> {
|
||||
crate::ai::insight_chat::run_streaming_agentic_loop_with_entry(
|
||||
&self.generator,
|
||||
@@ -1340,6 +1365,7 @@ impl InsightChatService {
|
||||
active_persona,
|
||||
max_iterations,
|
||||
entry,
|
||||
cx,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -1347,6 +1373,7 @@ impl InsightChatService {
|
||||
async fn run_streaming_turn(
|
||||
self: Arc<Self>,
|
||||
req: ChatTurnRequest,
|
||||
cx: opentelemetry::Context,
|
||||
tx: tokio::sync::mpsc::Sender<ChatStreamEvent>,
|
||||
) -> Result<()> {
|
||||
if req.user_message.trim().is_empty() {
|
||||
@@ -1376,17 +1403,17 @@ impl InsightChatService {
|
||||
// library bootstraps cleanly, even when another library has
|
||||
// an insight for the same rel_path.
|
||||
let existing_insight = {
|
||||
let cx = opentelemetry::Context::new();
|
||||
let db_cx = opentelemetry::Context::new();
|
||||
let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao");
|
||||
dao.get_current_insight_for_library(&cx, req.library_id, &normalized)
|
||||
dao.get_current_insight_for_library(&db_cx, req.library_id, &normalized)
|
||||
.map_err(|e| anyhow!("failed to load insight: {:?}", e))?
|
||||
};
|
||||
|
||||
if req.regenerate || existing_insight.is_none() {
|
||||
return self.run_bootstrap_streaming(req, normalized, tx).await;
|
||||
return self.run_bootstrap_streaming(req, normalized, cx, tx).await;
|
||||
}
|
||||
let insight = existing_insight.expect("just checked Some above");
|
||||
self.run_continuation_streaming(req, normalized, insight, tx)
|
||||
self.run_continuation_streaming(req, normalized, cx, insight, tx)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1398,6 +1425,7 @@ impl InsightChatService {
|
||||
&self,
|
||||
req: ChatTurnRequest,
|
||||
normalized: String,
|
||||
cx: opentelemetry::Context,
|
||||
insight: crate::database::models::PhotoInsight,
|
||||
tx: tokio::sync::mpsc::Sender<ChatStreamEvent>,
|
||||
) -> Result<()> {
|
||||
@@ -1495,6 +1523,7 @@ impl InsightChatService {
|
||||
&active_persona,
|
||||
max_iterations,
|
||||
&tx,
|
||||
&cx,
|
||||
)
|
||||
.await?;
|
||||
let AgenticLoopOutcome {
|
||||
@@ -1606,6 +1635,7 @@ impl InsightChatService {
|
||||
&self,
|
||||
req: ChatTurnRequest,
|
||||
normalized: String,
|
||||
cx: opentelemetry::Context,
|
||||
tx: tokio::sync::mpsc::Sender<ChatStreamEvent>,
|
||||
) -> Result<()> {
|
||||
let active_persona = req
|
||||
@@ -1644,7 +1674,12 @@ impl InsightChatService {
|
||||
// would otherwise default to today's date and miss every
|
||||
// historical photo).
|
||||
let exif = self.generator.fetch_exif(&normalized);
|
||||
let date_taken_str = resolve_date_taken_for_context(&exif, &normalized);
|
||||
let date_taken_str = resolve_date_taken_for_context(
|
||||
&exif,
|
||||
&normalized,
|
||||
self.generator.display_zone(),
|
||||
crate::camera_tz::server_zone(),
|
||||
);
|
||||
let gps = exif
|
||||
.as_ref()
|
||||
.and_then(|e| match (e.gps_latitude, e.gps_longitude) {
|
||||
@@ -1657,7 +1692,7 @@ impl InsightChatService {
|
||||
// images_inline backends send images directly to the chat model.
|
||||
let visual_block = if !backend.images_inline {
|
||||
match image_base64.as_deref() {
|
||||
Some(b64) => match backend.local().describe_image(b64).await {
|
||||
Some(b64) => match backend.local().describe_image(&cx, b64).await {
|
||||
Ok(desc) => {
|
||||
format!("Visual description (from local vision model):\n{}\n", desc)
|
||||
}
|
||||
@@ -1724,6 +1759,7 @@ impl InsightChatService {
|
||||
&active_persona,
|
||||
max_iterations,
|
||||
&tx,
|
||||
&cx,
|
||||
)
|
||||
.await?;
|
||||
let AgenticLoopOutcome {
|
||||
@@ -1806,6 +1842,7 @@ impl InsightChatService {
|
||||
active_persona: &str,
|
||||
max_iterations: usize,
|
||||
tx: &tokio::sync::mpsc::Sender<ChatStreamEvent>,
|
||||
cx: &opentelemetry::Context,
|
||||
) -> Result<AgenticLoopOutcome> {
|
||||
let mut tool_calls_made = 0usize;
|
||||
let mut iterations_used = 0usize;
|
||||
@@ -1824,7 +1861,7 @@ impl InsightChatService {
|
||||
|
||||
let mut stream = backend
|
||||
.chat()
|
||||
.chat_with_tools_stream(messages.clone(), tools.clone())
|
||||
.chat_with_tools_stream(cx, messages.clone(), tools.clone())
|
||||
.await?;
|
||||
|
||||
let mut final_message: Option<ChatMessage> = None;
|
||||
@@ -1918,7 +1955,7 @@ impl InsightChatService {
|
||||
let synthetic_idx = push_synthetic_final_prompt(messages);
|
||||
let mut stream = backend
|
||||
.chat()
|
||||
.chat_with_tools_stream(messages.clone(), vec![])
|
||||
.chat_with_tools_stream(cx, messages.clone(), vec![])
|
||||
.await?;
|
||||
let mut final_message: Option<ChatMessage> = None;
|
||||
while let Some(ev) = stream.next().await {
|
||||
@@ -2020,24 +2057,33 @@ fn build_bootstrap_system_message(
|
||||
}
|
||||
|
||||
/// Resolve a human-readable `YYYY-MM-DD` date string for the photo
|
||||
/// context block. Waterfall: EXIF `date_taken` → filename pattern →
|
||||
/// `None`. The fs-time fallback that `generate_agentic_insight_for_photo`
|
||||
/// context block (ADR-0001). Waterfall: the row's display wall (new
|
||||
/// contract when converted, per-source convention while unconverted) →
|
||||
/// filename pattern (wall patterns as-is, unix-timestamp patterns in the
|
||||
/// display zone) → `None`. The fs-time fallback that insight generation
|
||||
/// uses is intentionally NOT applied here — for chat we'd rather show
|
||||
/// "unknown" than a misleading inode mtime as the photo's date.
|
||||
fn resolve_date_taken_for_context(
|
||||
exif: &Option<crate::database::models::ImageExif>,
|
||||
file_path: &str,
|
||||
display_zone: chrono_tz::Tz,
|
||||
server_zone: Option<chrono_tz::Tz>,
|
||||
) -> Option<String> {
|
||||
let from_exif = exif
|
||||
.as_ref()
|
||||
.and_then(|e| e.date_taken)
|
||||
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
|
||||
.map(|dt| dt.format("%Y-%m-%d").to_string());
|
||||
if from_exif.is_some() {
|
||||
return from_exif;
|
||||
if let Some(e) = exif
|
||||
&& let Some(wall) = crate::date_view::row_display_wall(e, display_zone, server_zone)
|
||||
{
|
||||
return Some(wall.format("%Y-%m-%d").to_string());
|
||||
}
|
||||
crate::memories::extract_date_from_filename(file_path)
|
||||
.map(|fd| fd.legacy_datetime().format("%Y-%m-%d").to_string())
|
||||
crate::memories::extract_date_from_filename(file_path).and_then(|fd| {
|
||||
let wall = match &fd {
|
||||
crate::memories::FilenameDate::Wall(w) => *w,
|
||||
crate::memories::FilenameDate::Utc(dt) => display_zone
|
||||
.timestamp_opt(dt.timestamp(), 0)
|
||||
.single()?
|
||||
.naive_local(),
|
||||
};
|
||||
Some(wall.format("%Y-%m-%d").to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate a stored→effective backend transition for a chat continuation.
|
||||
@@ -2289,6 +2335,7 @@ pub async fn run_streaming_agentic_loop_with_entry(
|
||||
active_persona: &str,
|
||||
max_iterations: usize,
|
||||
entry: &Arc<TurnEntry>,
|
||||
cx: &opentelemetry::Context,
|
||||
) -> Result<AgenticLoopOutcome> {
|
||||
let mut tool_calls_made = 0usize;
|
||||
let mut iterations_used = 0usize;
|
||||
@@ -2318,7 +2365,7 @@ pub async fn run_streaming_agentic_loop_with_entry(
|
||||
|
||||
let mut stream = backend
|
||||
.chat()
|
||||
.chat_with_tools_stream(messages.clone(), tools.clone())
|
||||
.chat_with_tools_stream(cx, messages.clone(), tools.clone())
|
||||
.await?;
|
||||
|
||||
let mut final_message: Option<ChatMessage> = None;
|
||||
@@ -2401,7 +2448,7 @@ pub async fn run_streaming_agentic_loop_with_entry(
|
||||
let synthetic_idx = push_synthetic_final_prompt(messages);
|
||||
let mut stream = backend
|
||||
.chat()
|
||||
.chat_with_tools_stream(messages.clone(), vec![])
|
||||
.chat_with_tools_stream(cx, messages.clone(), vec![])
|
||||
.await?;
|
||||
let mut final_message: Option<ChatMessage> = None;
|
||||
while let Some(ev) = stream.next().await {
|
||||
@@ -3385,7 +3432,12 @@ mod tests {
|
||||
original_camera_offset_minutes: None,
|
||||
original_date_taken_tz_source: None,
|
||||
});
|
||||
let out = resolve_date_taken_for_context(&exif, "Screenshot_2014-06-01.png");
|
||||
let out = resolve_date_taken_for_context(
|
||||
&exif,
|
||||
"Screenshot_2014-06-01.png",
|
||||
chrono_tz::Tz::America__New_York,
|
||||
Some(chrono_tz::Tz::America__New_York),
|
||||
);
|
||||
assert_eq!(out.as_deref(), Some("2021-08-15"));
|
||||
}
|
||||
|
||||
@@ -3394,13 +3446,157 @@ mod tests {
|
||||
// memories::extract_date_from_filename requires date+time in
|
||||
// the filename — date-only patterns aren't matched. Use the
|
||||
// canonical screenshot pattern for the regression case.
|
||||
let out = resolve_date_taken_for_context(&None, "Screenshot_2014-06-01-20-44-50.png");
|
||||
let out = resolve_date_taken_for_context(
|
||||
&None,
|
||||
"Screenshot_2014-06-01-20-44-50.png",
|
||||
chrono_tz::Tz::America__New_York,
|
||||
Some(chrono_tz::Tz::America__New_York),
|
||||
);
|
||||
assert_eq!(out.as_deref(), Some("2014-06-01"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_taken_for_context_returns_none_when_neither_source() {
|
||||
let out = resolve_date_taken_for_context(&None, "DSC_5171.JPG");
|
||||
let out = resolve_date_taken_for_context(
|
||||
&None,
|
||||
"DSC_5171.JPG",
|
||||
chrono_tz::Tz::America__New_York,
|
||||
Some(chrono_tz::Tz::America__New_York),
|
||||
);
|
||||
assert!(out.is_none());
|
||||
}
|
||||
|
||||
/// AC-4 / AC-5: a Japan-offset (+9 h) photo anchored at local 21:00.
|
||||
/// The AI is told the camera wall clock (instant + offset — a midnight
|
||||
/// rollover relative to the owner's display zone is expected and
|
||||
/// correct), while the SMS/location/calendar windows anchor on the
|
||||
/// true instant with zero offset math.
|
||||
#[test]
|
||||
fn japan_offset_row_ai_date_is_camera_wall_and_anchor_unshifted() {
|
||||
use chrono::NaiveDate;
|
||||
|
||||
// 2024-07-05 23:30 UTC = 2024-07-06 08:30 JST = 2024-07-05 19:30 EDT.
|
||||
let instant = NaiveDate::from_ymd_opt(2024, 7, 5)
|
||||
.unwrap()
|
||||
.and_hms_opt(23, 30, 0)
|
||||
.unwrap()
|
||||
.and_utc();
|
||||
let date_utc = instant.timestamp();
|
||||
|
||||
let row = crate::database::models::ImageExif {
|
||||
id: 0,
|
||||
library_id: 1,
|
||||
file_path: "tokyo.jpg".to_string(),
|
||||
camera_make: None,
|
||||
camera_model: None,
|
||||
lens_model: None,
|
||||
width: None,
|
||||
height: None,
|
||||
orientation: None,
|
||||
gps_latitude: None,
|
||||
gps_longitude: None,
|
||||
gps_altitude: None,
|
||||
focal_length: None,
|
||||
aperture: None,
|
||||
shutter_speed: None,
|
||||
iso: None,
|
||||
date_taken: Some(date_utc),
|
||||
created_time: 0,
|
||||
last_modified: 0,
|
||||
content_hash: None,
|
||||
size_bytes: None,
|
||||
phash_64: None,
|
||||
dhash_64: None,
|
||||
duplicate_of_hash: None,
|
||||
duplicate_decided_at: None,
|
||||
date_taken_source: Some("exif".to_string()),
|
||||
original_date_taken: None,
|
||||
original_date_taken_source: None,
|
||||
clip_embedding: None,
|
||||
clip_model_version: None,
|
||||
date_utc: Some(date_utc),
|
||||
camera_offset_minutes: Some(540),
|
||||
date_taken_tz_source: Some("offset_tag".to_string()),
|
||||
original_date_utc: None,
|
||||
original_camera_offset_minutes: None,
|
||||
original_date_taken_tz_source: None,
|
||||
};
|
||||
|
||||
let ny = chrono_tz::Tz::America__New_York;
|
||||
|
||||
// The prompt/UI date is the camera wall: 2024-07-06 08:30 JST —
|
||||
// one day AFTER the owner's display-zone date (midnight rollover
|
||||
// is correct here, the AI must see what the camera saw).
|
||||
let ai_date = resolve_date_taken_for_context(&Some(row.clone()), "tokyo.jpg", ny, Some(ny));
|
||||
assert_eq!(ai_date.as_deref(), Some("2024-07-06"));
|
||||
|
||||
// The proximity windows (SMS / location / calendar) anchor on the
|
||||
// true instant — no +9 h, no -4 h, nothing.
|
||||
let anchor = crate::date_view::row_true_instant(&row, ny, Some(ny));
|
||||
assert_eq!(anchor, Some(date_utc));
|
||||
}
|
||||
|
||||
/// AC-6: an unconverted container video row — the AI context date is
|
||||
/// the display-zone rendering of the true instant (the raw UTC creation
|
||||
/// time), matching the metadata/UI date. No 4–5 h offset, no midnight
|
||||
/// rollover relative to the UTC-naive reading.
|
||||
#[test]
|
||||
fn unconverted_video_row_ai_date_matches_display_wall() {
|
||||
use chrono::NaiveDate;
|
||||
|
||||
// The container's raw creation string is "2024-07-06 01:30:00"
|
||||
// (a true UTC instant). The old server parsed that naive string
|
||||
// as server-local (EDT) → legacy epoch = 05:30 UTC. The AI must
|
||||
// be told the owner's evening — July 5 — not July 6 (the UTC
|
||||
// date the old code would have rendered).
|
||||
let legacy = NaiveDate::from_ymd_opt(2024, 7, 6)
|
||||
.unwrap()
|
||||
.and_hms_opt(5, 30, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp();
|
||||
|
||||
let row = Some(crate::database::models::ImageExif {
|
||||
id: 0,
|
||||
library_id: 1,
|
||||
file_path: "movie.mp4".to_string(),
|
||||
camera_make: None,
|
||||
camera_model: None,
|
||||
lens_model: None,
|
||||
width: None,
|
||||
height: None,
|
||||
orientation: None,
|
||||
gps_latitude: None,
|
||||
gps_longitude: None,
|
||||
gps_altitude: None,
|
||||
focal_length: None,
|
||||
aperture: None,
|
||||
shutter_speed: None,
|
||||
iso: None,
|
||||
date_taken: Some(legacy),
|
||||
created_time: 0,
|
||||
last_modified: 0,
|
||||
content_hash: None,
|
||||
size_bytes: None,
|
||||
phash_64: None,
|
||||
dhash_64: None,
|
||||
duplicate_of_hash: None,
|
||||
duplicate_decided_at: None,
|
||||
date_taken_source: Some("exiftool".to_string()),
|
||||
original_date_taken: None,
|
||||
original_date_taken_source: None,
|
||||
clip_embedding: None,
|
||||
clip_model_version: None,
|
||||
date_utc: None,
|
||||
camera_offset_minutes: None,
|
||||
date_taken_tz_source: None,
|
||||
original_date_utc: None,
|
||||
original_camera_offset_minutes: None,
|
||||
original_date_taken_tz_source: None,
|
||||
});
|
||||
|
||||
let ny = chrono_tz::Tz::America__New_York;
|
||||
let ai_date = resolve_date_taken_for_context(&row, "movie.mp4", ny, Some(ny));
|
||||
assert_eq!(ai_date.as_deref(), Some("2024-07-05"));
|
||||
}
|
||||
}
|
||||
+153
-95
@@ -1,12 +1,13 @@
|
||||
use anyhow::Result;
|
||||
use base64::Engine as _;
|
||||
use chrono::{DateTime, Local, NaiveDate, Utc};
|
||||
use chrono::{DateTime, Local, NaiveDate, TimeZone, Utc};
|
||||
use image::ImageFormat;
|
||||
use opentelemetry::KeyValue;
|
||||
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
|
||||
use serde::Deserialize;
|
||||
use std::fs::File;
|
||||
use std::io::Cursor;
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::ai::apollo_client::{ApolloClient, ApolloPlace};
|
||||
@@ -23,7 +24,7 @@ use crate::database::{
|
||||
LocationHistoryDao, SearchHistoryDao,
|
||||
};
|
||||
use crate::libraries::Library;
|
||||
use crate::memories::{FilenameDate, extract_date_from_filename};
|
||||
use crate::memories::extract_date_from_filename;
|
||||
use crate::otel::global_tracer;
|
||||
use crate::tags::TagDao;
|
||||
use crate::utils::{earliest_fs_time, normalize_path, retry_with_backoff, retry_with_backoff_if};
|
||||
@@ -161,6 +162,10 @@ pub struct InsightGenerator {
|
||||
// the per-persona "reviewed-only" strict-mode toggle).
|
||||
persona_dao: Arc<Mutex<Box<dyn crate::database::PersonaDao>>>,
|
||||
|
||||
/// Owner display-zone prefs (ADR-0001): prompt dates and the chat
|
||||
/// context block render in this zone.
|
||||
user_ai_prefs_dao: Arc<Mutex<Box<dyn crate::database::UserAiPrefsDao>>>,
|
||||
|
||||
libraries: Vec<Library>,
|
||||
}
|
||||
|
||||
@@ -201,6 +206,7 @@ impl InsightGenerator {
|
||||
face_dao: Arc<Mutex<Box<dyn crate::faces::FaceDao>>>,
|
||||
knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>>,
|
||||
persona_dao: Arc<Mutex<Box<dyn crate::database::PersonaDao>>>,
|
||||
user_ai_prefs_dao: Arc<Mutex<Box<dyn crate::database::UserAiPrefsDao>>>,
|
||||
libraries: Vec<Library>,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -219,10 +225,86 @@ impl InsightGenerator {
|
||||
face_dao,
|
||||
knowledge_dao,
|
||||
persona_dao,
|
||||
user_ai_prefs_dao,
|
||||
libraries,
|
||||
}
|
||||
}
|
||||
|
||||
/// The owner's display zone (ADR-0001): prefs `display_tz` when set and
|
||||
/// valid, the app default otherwise. Read per call so a pref change
|
||||
/// takes effect without a restart; a DB error degrades to the default.
|
||||
pub fn display_zone(&self) -> chrono_tz::Tz {
|
||||
let name = {
|
||||
let stored = self
|
||||
.user_ai_prefs_dao
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut dao| dao.get_prefs(&opentelemetry::Context::new()).ok().flatten())
|
||||
.and_then(|p| p.display_tz);
|
||||
crate::camera_tz::display_zone_for(stored.as_deref())
|
||||
};
|
||||
chrono_tz::Tz::from_str(&name).unwrap_or(chrono_tz::Tz::America__New_York)
|
||||
}
|
||||
|
||||
/// The photo's date anchor pair (ADR-0001), shared by the non-agentic
|
||||
/// and agentic insight paths:
|
||||
/// - the **true instant** — the row's new-contract instant when present,
|
||||
/// the legacy value under its per-source convention while unconverted,
|
||||
/// then filename / fs-time / now — used as the zero-shift anchor for
|
||||
/// the SMS / location / calendar proximity windows;
|
||||
/// - the **prompt-facing display wall** — the row's display wall (camera
|
||||
/// wall when the row carries an offset, display-zone rendering
|
||||
/// otherwise) — the date the AI is told, matching what the owner sees.
|
||||
fn resolve_date_anchor(
|
||||
&self,
|
||||
exif: &Option<crate::database::models::ImageExif>,
|
||||
file_path: &str,
|
||||
log_label: &str,
|
||||
) -> (i64, NaiveDate) {
|
||||
let display_zone = self.display_zone();
|
||||
let server_zone = crate::camera_tz::server_zone();
|
||||
let timestamp = exif
|
||||
.as_ref()
|
||||
.and_then(|e| crate::date_view::row_true_instant(e, display_zone, server_zone))
|
||||
.unwrap_or_else(|| {
|
||||
log::warn!("No date in EXIF for {}, trying filename", file_path);
|
||||
|
||||
extract_date_from_filename(file_path)
|
||||
.and_then(|fd| crate::date_view::filename_true_instant(&fd, server_zone))
|
||||
.or_else(|| {
|
||||
let full_path = self.resolve_full_path(file_path)?;
|
||||
File::open(&full_path)
|
||||
.and_then(|f| f.metadata())
|
||||
.inspect_err(|e| {
|
||||
log::warn!(
|
||||
"Failed to get file timestamp for {} {}: {}",
|
||||
log_label,
|
||||
file_path,
|
||||
e
|
||||
)
|
||||
})
|
||||
.ok()
|
||||
.and_then(|m| earliest_fs_time(&m))
|
||||
.map(|t| DateTime::<Utc>::from(t).timestamp())
|
||||
})
|
||||
.unwrap_or_else(|| Utc::now().timestamp())
|
||||
});
|
||||
|
||||
let date_taken = exif
|
||||
.as_ref()
|
||||
.and_then(|e| crate::date_view::row_display_wall(e, display_zone, server_zone))
|
||||
.map(|w| w.date())
|
||||
.or_else(|| {
|
||||
display_zone
|
||||
.timestamp_opt(timestamp, 0)
|
||||
.single()
|
||||
.map(|dt| dt.date_naive())
|
||||
})
|
||||
.unwrap_or_else(|| Utc::now().date_naive());
|
||||
|
||||
(timestamp, date_taken)
|
||||
}
|
||||
|
||||
/// Accessor for the insight DAO (used by async job completion to
|
||||
/// look up the stored insight id).
|
||||
pub fn insight_dao(&self) -> &Arc<Mutex<Box<dyn InsightDao>>> {
|
||||
@@ -564,7 +646,8 @@ impl InsightGenerator {
|
||||
// (`LLM_BACKEND` switch). Must match the backend that populated the
|
||||
// daily-summary embeddings or similarity search will be garbage.
|
||||
let query_embedding =
|
||||
crate::ai::embed_query(&self.ollama, self.llamacpp.as_deref(), &query).await?;
|
||||
crate::ai::embed_query(&search_cx, &self.ollama, self.llamacpp.as_deref(), &query)
|
||||
.await?;
|
||||
|
||||
// Search for similar daily summaries with time-based weighting
|
||||
// This prioritizes summaries temporally close to the query date
|
||||
@@ -630,7 +713,8 @@ impl InsightGenerator {
|
||||
// Must use the same backend that populated the daily-summary
|
||||
// embeddings or similarity search is garbage (see embed_one docs).
|
||||
let query_embedding =
|
||||
crate::ai::embed_query(&self.ollama, self.llamacpp.as_deref(), query).await?;
|
||||
crate::ai::embed_query(&search_cx, &self.ollama, self.llamacpp.as_deref(), query)
|
||||
.await?;
|
||||
|
||||
let mut summary_dao = self
|
||||
.daily_summary_dao
|
||||
@@ -716,7 +800,9 @@ impl InsightGenerator {
|
||||
let calendar_cx = parent_cx.with_span(span);
|
||||
|
||||
let query_embedding = if let Some(loc) = location {
|
||||
match crate::ai::embed_query(&self.ollama, self.llamacpp.as_deref(), loc).await {
|
||||
match crate::ai::embed_query(&calendar_cx, &self.ollama, self.llamacpp.as_deref(), loc)
|
||||
.await
|
||||
{
|
||||
Ok(emb) => Some(emb),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to generate embedding for location '{}': {}", loc, e);
|
||||
@@ -887,18 +973,23 @@ impl InsightGenerator {
|
||||
)
|
||||
};
|
||||
|
||||
let query_embedding =
|
||||
match crate::ai::embed_query(&self.ollama, self.llamacpp.as_deref(), &query_text).await
|
||||
{
|
||||
Ok(emb) => emb,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to generate search embedding: {}", e);
|
||||
search_cx.span().set_status(Status::Error {
|
||||
description: e.to_string().into(),
|
||||
});
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let query_embedding = match crate::ai::embed_query(
|
||||
&search_cx,
|
||||
&self.ollama,
|
||||
self.llamacpp.as_deref(),
|
||||
&query_text,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(emb) => emb,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to generate search embedding: {}", e);
|
||||
search_cx.span().set_status(Status::Error {
|
||||
description: e.to_string().into(),
|
||||
});
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let searches = {
|
||||
let mut dao = self
|
||||
@@ -1055,35 +1146,8 @@ impl InsightGenerator {
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))?
|
||||
};
|
||||
|
||||
// Get full timestamp for proximity-based message filtering
|
||||
let timestamp = if let Some(ts) = exif.as_ref().and_then(|e| e.date_taken) {
|
||||
ts
|
||||
} else {
|
||||
log::warn!("No date_taken in EXIF for {}, trying filename", file_path);
|
||||
|
||||
extract_date_from_filename(&file_path)
|
||||
.map(FilenameDate::legacy_timestamp)
|
||||
.or_else(|| {
|
||||
let full_path = self.resolve_full_path(&file_path)?;
|
||||
File::open(&full_path)
|
||||
.and_then(|f| f.metadata())
|
||||
.inspect_err(|e| {
|
||||
log::warn!(
|
||||
"Failed to get file timestamp for insight {}: {}",
|
||||
file_path,
|
||||
e
|
||||
)
|
||||
})
|
||||
.ok()
|
||||
.and_then(|m| earliest_fs_time(&m))
|
||||
.map(|t| DateTime::<Utc>::from(t).timestamp())
|
||||
})
|
||||
.unwrap_or_else(|| Utc::now().timestamp())
|
||||
};
|
||||
|
||||
let date_taken = DateTime::from_timestamp(timestamp, 0)
|
||||
.map(|dt| dt.date_naive())
|
||||
.unwrap_or_else(|| Utc::now().date_naive());
|
||||
// True-instant anchor + prompt-facing display wall (ADR-0001).
|
||||
let (timestamp, date_taken) = self.resolve_date_anchor(&exif, &file_path, "insight");
|
||||
|
||||
// 3. Extract contact name from file path
|
||||
let contact = Self::extract_contact_from_path(&file_path);
|
||||
@@ -1199,7 +1263,10 @@ impl InsightGenerator {
|
||||
|
||||
// Generate brief photo description for RAG enrichment (vision models only)
|
||||
let photo_description: Option<String> = if let Some(ref img_b64) = image_base64 {
|
||||
match ollama_client.generate_photo_description(img_b64).await {
|
||||
match ollama_client
|
||||
.generate_photo_description(&insight_cx, img_b64)
|
||||
.await
|
||||
{
|
||||
Ok(desc) => {
|
||||
log::info!("Photo description for RAG enrichment: {}", desc);
|
||||
Some(desc)
|
||||
@@ -1268,7 +1335,7 @@ impl InsightGenerator {
|
||||
if !immediate_messages.is_empty() {
|
||||
// Step 2: Extract topics from immediate messages to enrich RAG query
|
||||
let topics = self
|
||||
.extract_topics_from_messages(&immediate_messages, &ollama_client)
|
||||
.extract_topics_from_messages(&immediate_messages, &ollama_client, &insight_cx)
|
||||
.await;
|
||||
|
||||
log::info!("Extracted topics for query enrichment: {:?}", topics);
|
||||
@@ -1324,6 +1391,7 @@ impl InsightGenerator {
|
||||
&immediate_messages,
|
||||
&ollama_client,
|
||||
custom_system_prompt.as_deref(),
|
||||
&insight_cx,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| String::from("No immediate context"));
|
||||
@@ -1333,6 +1401,7 @@ impl InsightGenerator {
|
||||
&historical_messages,
|
||||
&ollama_client,
|
||||
custom_system_prompt.as_deref(),
|
||||
&insight_cx,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| String::from("No historical context"));
|
||||
@@ -1351,6 +1420,7 @@ impl InsightGenerator {
|
||||
&immediate_messages,
|
||||
&ollama_client,
|
||||
custom_system_prompt.as_deref(),
|
||||
&insight_cx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -1361,6 +1431,7 @@ impl InsightGenerator {
|
||||
&immediate_messages,
|
||||
&ollama_client,
|
||||
custom_system_prompt.as_deref(),
|
||||
&insight_cx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -1386,6 +1457,7 @@ impl InsightGenerator {
|
||||
&rag_messages,
|
||||
&ollama_client,
|
||||
custom_system_prompt.as_deref(),
|
||||
&insight_cx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -1418,7 +1490,7 @@ impl InsightGenerator {
|
||||
if !sms_messages.is_empty() {
|
||||
match self
|
||||
.sms_client
|
||||
.summarize_context(&sms_messages, &ollama_client)
|
||||
.summarize_context(&sms_messages, &ollama_client, &insight_cx)
|
||||
.await
|
||||
{
|
||||
Ok(summary) => {
|
||||
@@ -1502,6 +1574,7 @@ impl InsightGenerator {
|
||||
// 10. Generate summary first, then derive title from the summary
|
||||
let summary = ollama_client
|
||||
.generate_photo_summary(
|
||||
&insight_cx,
|
||||
date_taken,
|
||||
location.as_deref(),
|
||||
contact.as_deref(),
|
||||
@@ -1512,7 +1585,7 @@ impl InsightGenerator {
|
||||
.await?;
|
||||
|
||||
let title = ollama_client
|
||||
.generate_photo_title(&summary, custom_system_prompt.as_deref())
|
||||
.generate_photo_title(&insight_cx, &summary, custom_system_prompt.as_deref())
|
||||
.await?;
|
||||
|
||||
log::info!("Generated title: {}", title);
|
||||
@@ -1579,6 +1652,7 @@ impl InsightGenerator {
|
||||
&self,
|
||||
messages: &[crate::ai::SmsMessage],
|
||||
ollama: &OllamaClient,
|
||||
cx: &opentelemetry::Context,
|
||||
) -> Vec<String> {
|
||||
if messages.is_empty() {
|
||||
return Vec::new();
|
||||
@@ -1614,7 +1688,7 @@ Return ONLY the comma-separated list, nothing else."#,
|
||||
);
|
||||
|
||||
match ollama
|
||||
.generate(&prompt, Some("You are an entity extraction assistant. Extract proper nouns, people, places, and domain-specific terms that provide context."))
|
||||
.generate(cx, &prompt, Some("You are an entity extraction assistant. Extract proper nouns, people, places, and domain-specific terms that provide context."))
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
@@ -1649,6 +1723,7 @@ Return ONLY the comma-separated list, nothing else."#,
|
||||
messages: &[String],
|
||||
ollama: &OllamaClient,
|
||||
custom_system: Option<&str>,
|
||||
cx: &opentelemetry::Context,
|
||||
) -> Option<String> {
|
||||
if messages.is_empty() {
|
||||
return None;
|
||||
@@ -1669,7 +1744,7 @@ Return ONLY the summary, nothing else."#,
|
||||
let system = custom_system
|
||||
.unwrap_or("You are a context summarization assistant. Be concise and factual.");
|
||||
|
||||
match ollama.generate(&prompt, Some(system)).await {
|
||||
match ollama.generate(cx, &prompt, Some(system)).await {
|
||||
Ok(summary) => Some(summary),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to summarize messages: {}", e);
|
||||
@@ -1685,6 +1760,7 @@ Return ONLY the summary, nothing else."#,
|
||||
messages: &[crate::ai::SmsMessage],
|
||||
ollama: &OllamaClient,
|
||||
custom_system: Option<&str>,
|
||||
cx: &opentelemetry::Context,
|
||||
) -> Option<String> {
|
||||
if messages.is_empty() {
|
||||
return None;
|
||||
@@ -1730,7 +1806,7 @@ Return ONLY the summary, nothing else."#,
|
||||
"You are a context summarization assistant. Be detailed and factual, preserving important context.",
|
||||
);
|
||||
|
||||
match ollama.generate(&prompt, Some(system)).await {
|
||||
match ollama.generate(cx, &prompt, Some(system)).await {
|
||||
Ok(summary) => Some(summary),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to summarize immediate context: {}", e);
|
||||
@@ -1774,7 +1850,7 @@ Return ONLY the summary, nothing else."#,
|
||||
"get_file_tags" => self.tool_get_file_tags(arguments, cx).await,
|
||||
"get_faces_in_photo" => self.tool_get_faces_in_photo(arguments, cx).await,
|
||||
"describe_photo" => {
|
||||
self.tool_describe_photo(backend.local(), image_base64)
|
||||
self.tool_describe_photo(backend.local(), image_base64, cx)
|
||||
.await
|
||||
}
|
||||
"reverse_geocode" => self.tool_reverse_geocode(arguments).await,
|
||||
@@ -1825,7 +1901,7 @@ Return ONLY the summary, nothing else."#,
|
||||
&self,
|
||||
args: &serde_json::Value,
|
||||
local: &dyn LlmClient,
|
||||
_cx: &opentelemetry::Context,
|
||||
cx: &opentelemetry::Context,
|
||||
) -> String {
|
||||
let query = match args.get("query").and_then(|v| v.as_str()) {
|
||||
Some(q) => q.to_string(),
|
||||
@@ -1889,7 +1965,10 @@ Return ONLY the summary, nothing else."#,
|
||||
};
|
||||
|
||||
let final_results = if rerank_enabled && results.len() > limit {
|
||||
match self.rerank_with_llm(&query, &results, limit, local).await {
|
||||
match self
|
||||
.rerank_with_llm(cx, &query, &results, limit, local)
|
||||
.await
|
||||
{
|
||||
Ok(reordered) => reordered,
|
||||
Err(e) => {
|
||||
log::warn!("rerank failed, using vector order: {}", e);
|
||||
@@ -1912,6 +1991,7 @@ Return ONLY the summary, nothing else."#,
|
||||
/// tool_result. On parse failure we fall back to the input order.
|
||||
async fn rerank_with_llm(
|
||||
&self,
|
||||
cx: &opentelemetry::Context,
|
||||
query: &str,
|
||||
candidates: &[String],
|
||||
limit: usize,
|
||||
@@ -1955,7 +2035,7 @@ Return ONLY the summary, nothing else."#,
|
||||
let started = std::time::Instant::now();
|
||||
let system =
|
||||
Some("You are a terse relevance ranker. You output only numbers separated by commas.");
|
||||
let response = local.generate(&prompt, system, None).await?;
|
||||
let response = local.generate(cx, &prompt, system, None).await?;
|
||||
log::info!(
|
||||
"rerank: finished in {} ms (prompt={} chars)",
|
||||
started.elapsed().as_millis(),
|
||||
@@ -2565,10 +2645,11 @@ Return ONLY the summary, nothing else."#,
|
||||
&self,
|
||||
local: &dyn LlmClient,
|
||||
image_base64: &Option<String>,
|
||||
cx: &opentelemetry::Context,
|
||||
) -> String {
|
||||
log::info!("tool_describe_photo: generating visual description");
|
||||
match image_base64 {
|
||||
Some(img) => match local.describe_image(img).await {
|
||||
Some(img) => match local.describe_image(cx, img).await {
|
||||
Ok(desc) => desc,
|
||||
Err(e) => format!("Error describing photo: {}", e),
|
||||
},
|
||||
@@ -2976,6 +3057,7 @@ Return ONLY the summary, nothing else."#,
|
||||
// configured local backend.
|
||||
let embed_text = format!("{} {}", name, description);
|
||||
let embedding: Option<Vec<u8>> = match crate::ai::embed_document(
|
||||
cx,
|
||||
&self.ollama,
|
||||
self.llamacpp.as_deref(),
|
||||
&embed_text,
|
||||
@@ -4147,34 +4229,9 @@ Return ONLY the summary, nothing else."#,
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))?
|
||||
};
|
||||
|
||||
// 4. Extract timestamp and contact
|
||||
let timestamp = if let Some(ts) = exif.as_ref().and_then(|e| e.date_taken) {
|
||||
ts
|
||||
} else {
|
||||
log::warn!("No date_taken in EXIF for {}, trying filename", file_path);
|
||||
extract_date_from_filename(&file_path)
|
||||
.map(FilenameDate::legacy_timestamp)
|
||||
.or_else(|| {
|
||||
let full_path = self.resolve_full_path(&file_path)?;
|
||||
File::open(&full_path)
|
||||
.and_then(|f| f.metadata())
|
||||
.inspect_err(|e| {
|
||||
log::warn!(
|
||||
"Failed to get file timestamp for agentic insight {}: {}",
|
||||
file_path,
|
||||
e
|
||||
)
|
||||
})
|
||||
.ok()
|
||||
.and_then(|m| earliest_fs_time(&m))
|
||||
.map(|t| DateTime::<Utc>::from(t).timestamp())
|
||||
})
|
||||
.unwrap_or_else(|| Utc::now().timestamp())
|
||||
};
|
||||
|
||||
let date_taken = DateTime::from_timestamp(timestamp, 0)
|
||||
.map(|dt| dt.date_naive())
|
||||
.unwrap_or_else(|| Utc::now().date_naive());
|
||||
// 4. Extract date and contact. The agentic prompt only needs the
|
||||
// display wall (ADR-0001) — its proximity windows are tool-driven.
|
||||
let (_, date_taken) = self.resolve_date_anchor(&exif, &file_path, "agentic insight");
|
||||
|
||||
let contact = Self::extract_contact_from_path(&file_path);
|
||||
log::info!("Agentic: date_taken={}, contact={:?}", date_taken, contact);
|
||||
@@ -4247,7 +4304,7 @@ Return ONLY the summary, nothing else."#,
|
||||
// the local backend so non-text work stays off OpenRouter.
|
||||
let inlined_visual_description: Option<String> = if !backend.images_inline {
|
||||
match image_base64.as_deref() {
|
||||
Some(b64) => match backend.local().describe_image(b64).await {
|
||||
Some(b64) => match backend.local().describe_image(&insight_cx, b64).await {
|
||||
Ok(desc) => {
|
||||
log::info!("{}: vision describe succeeded ({} chars)", kind, desc.len());
|
||||
Some(desc)
|
||||
@@ -4367,7 +4424,7 @@ Return ONLY the summary, nothing else."#,
|
||||
|
||||
let (response, prompt_tokens, eval_tokens) = backend
|
||||
.chat()
|
||||
.chat_with_tools(messages.clone(), tools.clone())
|
||||
.chat_with_tools(&loop_cx, messages.clone(), tools.clone())
|
||||
.await?;
|
||||
|
||||
last_prompt_eval_count = prompt_tokens;
|
||||
@@ -4436,7 +4493,7 @@ Return ONLY the summary, nothing else."#,
|
||||
)));
|
||||
let (final_response, prompt_tokens, eval_tokens) = backend
|
||||
.chat()
|
||||
.chat_with_tools(messages.clone(), tools.clone())
|
||||
.chat_with_tools(&loop_cx, messages.clone(), tools.clone())
|
||||
.await?;
|
||||
last_prompt_eval_count = prompt_tokens;
|
||||
last_eval_count = eval_tokens;
|
||||
@@ -4469,7 +4526,7 @@ Return ONLY the summary, nothing else."#,
|
||||
}
|
||||
let (resp2, pt2, et2) = backend
|
||||
.chat()
|
||||
.chat_with_tools(messages.clone(), vec![])
|
||||
.chat_with_tools(&loop_cx, messages.clone(), vec![])
|
||||
.await?;
|
||||
last_prompt_eval_count = pt2;
|
||||
last_eval_count = et2;
|
||||
@@ -4607,6 +4664,7 @@ Return ONLY the summary, nothing else."#,
|
||||
tools: Vec<Tool>,
|
||||
max_iter: usize,
|
||||
) -> Result<String> {
|
||||
let loop_cx = opentelemetry::Context::current();
|
||||
let mut final_content = String::new();
|
||||
|
||||
for iteration in 0..max_iter {
|
||||
@@ -4614,7 +4672,7 @@ Return ONLY the summary, nothing else."#,
|
||||
|
||||
let (response, _prompt_tokens, _eval_tokens) = backend
|
||||
.chat()
|
||||
.chat_with_tools(messages.clone(), tools.clone())
|
||||
.chat_with_tools(&loop_cx, messages.clone(), tools.clone())
|
||||
.await?;
|
||||
|
||||
// Sanitize tool call arguments before pushing back into history.
|
||||
@@ -4656,7 +4714,7 @@ Return ONLY the summary, nothing else."#,
|
||||
"",
|
||||
0,
|
||||
"",
|
||||
&opentelemetry::Context::new(),
|
||||
&loop_cx,
|
||||
)
|
||||
.await;
|
||||
messages.push(ChatMessage::tool_result(result));
|
||||
@@ -4680,7 +4738,7 @@ Return ONLY the summary, nothing else."#,
|
||||
));
|
||||
let (final_response, _, _) = backend
|
||||
.chat()
|
||||
.chat_with_tools(messages.clone(), tools.clone())
|
||||
.chat_with_tools(&loop_cx, messages.clone(), tools.clone())
|
||||
.await?;
|
||||
// Same bounded tool round as the insight loop: execute any last
|
||||
// tool call, then force a tool-less turn for the actual answer.
|
||||
@@ -4698,14 +4756,14 @@ Return ONLY the summary, nothing else."#,
|
||||
"",
|
||||
0,
|
||||
"",
|
||||
&opentelemetry::Context::new(),
|
||||
&loop_cx,
|
||||
)
|
||||
.await;
|
||||
messages.push(ChatMessage::tool_result(result));
|
||||
}
|
||||
let (resp2, _, _) = backend
|
||||
.chat()
|
||||
.chat_with_tools(messages.clone(), vec![])
|
||||
.chat_with_tools(&loop_cx, messages.clone(), vec![])
|
||||
.await?;
|
||||
final_content = resp2.content;
|
||||
} else {
|
||||
|
||||
+111
-26
@@ -20,8 +20,11 @@
|
||||
// insight_generator / insight_chat.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use anyhow::Context as _;
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use async_trait::async_trait;
|
||||
use opentelemetry::trace::{Span, Status};
|
||||
use opentelemetry::{Context, KeyValue};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
@@ -486,6 +489,7 @@ impl LlamaCppClient {
|
||||
async fn chat_completion_with_model(
|
||||
&self,
|
||||
model: &str,
|
||||
mut span: Option<&mut opentelemetry::global::BoxedSpan>,
|
||||
messages: Vec<ChatMessage>,
|
||||
tools: Vec<Tool>,
|
||||
) -> Result<(ChatMessage, Option<i32>, Option<i32>)> {
|
||||
@@ -517,19 +521,31 @@ impl LlamaCppClient {
|
||||
// Wait for any TTS synthesis to release the GPU before the request
|
||||
// timeout starts (see ai::gpu).
|
||||
let _gpu = crate::ai::gpu::llm_lease().await;
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&Value::Object(body))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("POST {} failed", url))?;
|
||||
let mut request = self.client.post(&url).json(&Value::Object(body));
|
||||
if let Some(s) = span.as_mut() {
|
||||
request = crate::otel::with_traceparent(request, s.span_context());
|
||||
}
|
||||
let resp = match request.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
if let Some(s) = span.as_mut() {
|
||||
s.set_status(Status::error("llama-swap request failed"));
|
||||
}
|
||||
return Err(e).with_context(|| format!("POST {} failed", url));
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
crate::otel::record_http_failure(
|
||||
span.as_deref_mut(),
|
||||
status.as_u16(),
|
||||
&format!("llama-swap chat request failed: {status}"),
|
||||
);
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
bail!("llama-swap chat request failed: {} — {}", status, body);
|
||||
}
|
||||
crate::otel::set_http_status(span.as_deref_mut(), resp.status().as_u16());
|
||||
|
||||
let parsed: Value = resp.json().await.context("parsing chat response")?;
|
||||
let choice = parsed
|
||||
@@ -562,14 +578,35 @@ impl LlamaCppClient {
|
||||
|
||||
log_timings(&parsed, prompt_tokens, completion_tokens);
|
||||
|
||||
if let Some(s) = span.as_mut() {
|
||||
if let Some(pt) = prompt_tokens {
|
||||
s.set_attribute(KeyValue::new("llm.tokens.input", pt as i64));
|
||||
}
|
||||
if let Some(ct) = completion_tokens {
|
||||
s.set_attribute(KeyValue::new("llm.tokens.output", ct as i64));
|
||||
}
|
||||
s.set_status(Status::Ok);
|
||||
}
|
||||
|
||||
Ok((chat_msg, prompt_tokens, completion_tokens))
|
||||
}
|
||||
|
||||
/// Start the `llm.request` span for an outgoing llama-swap call.
|
||||
fn start_request_span(
|
||||
&self,
|
||||
cx: &Context,
|
||||
operation: &str,
|
||||
model: &str,
|
||||
) -> opentelemetry::global::BoxedSpan {
|
||||
crate::otel::start_llm_request_span(cx, "llamacpp", operation, model)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for LlamaCppClient {
|
||||
async fn generate(
|
||||
&self,
|
||||
cx: &Context,
|
||||
prompt: &str,
|
||||
system: Option<&str>,
|
||||
images: Option<Vec<String>>,
|
||||
@@ -582,12 +619,15 @@ impl LlmClient for LlamaCppClient {
|
||||
user.images = images;
|
||||
messages.push(user);
|
||||
|
||||
let (reply, _, _) = self.chat_with_tools(messages, Vec::new()).await?;
|
||||
// Delegates to chat_with_tools, which starts the llm.request span
|
||||
// (operation=chat) — one span per HTTP request.
|
||||
let (reply, _, _) = self.chat_with_tools(cx, messages, Vec::new()).await?;
|
||||
Ok(reply.content)
|
||||
}
|
||||
|
||||
async fn chat_with_tools(
|
||||
&self,
|
||||
cx: &Context,
|
||||
messages: Vec<ChatMessage>,
|
||||
tools: Vec<Tool>,
|
||||
) -> Result<(ChatMessage, Option<i32>, Option<i32>)> {
|
||||
@@ -597,15 +637,25 @@ impl LlmClient for LlamaCppClient {
|
||||
messages.len(),
|
||||
tools.len()
|
||||
);
|
||||
self.chat_completion_with_model(&self.primary_model.clone(), messages, tools)
|
||||
.await
|
||||
let mut span = self.start_request_span(cx, "chat", &self.primary_model);
|
||||
self.chat_completion_with_model(
|
||||
&self.primary_model.clone(),
|
||||
Some(&mut span),
|
||||
messages,
|
||||
tools,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn chat_with_tools_stream(
|
||||
&self,
|
||||
cx: &Context,
|
||||
messages: Vec<ChatMessage>,
|
||||
tools: Vec<Tool>,
|
||||
) -> Result<BoxStream<'static, Result<LlmStreamEvent>>> {
|
||||
// The span outlives this method — it moves into the stream
|
||||
// generator and is finalized when the response drains.
|
||||
let mut span = self.start_request_span(cx, "chat_stream", &self.primary_model);
|
||||
let url = format!("{}/chat/completions", self.base_url);
|
||||
let roles: Vec<&str> = messages.iter().map(|m| m.role.as_str()).collect();
|
||||
log::debug!(
|
||||
@@ -639,19 +689,24 @@ impl LlmClient for LlamaCppClient {
|
||||
// timeout starts (see ai::gpu). The guard is moved into the stream
|
||||
// below so the lease spans the whole generation, not just the send.
|
||||
let gpu = crate::ai::gpu::llm_lease().await;
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&Value::Object(body))
|
||||
let mut request = self.client.post(&url).json(&Value::Object(body));
|
||||
request = crate::otel::with_traceparent(request, span.span_context());
|
||||
let resp = request
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("POST {} failed", url))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
crate::otel::record_http_failure(
|
||||
Some(&mut span),
|
||||
status.as_u16(),
|
||||
&format!("llama-swap stream request failed: {status}"),
|
||||
);
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
bail!("llama-swap stream request failed: {} — {}", status, body);
|
||||
}
|
||||
crate::otel::set_http_status(Some(&mut span), resp.status().as_u16());
|
||||
|
||||
let byte_stream = resp.bytes_stream();
|
||||
let stream = async_stream::stream! {
|
||||
@@ -760,6 +815,13 @@ impl LlmClient for LlamaCppClient {
|
||||
tool_calls,
|
||||
images: None,
|
||||
};
|
||||
if let Some(pt) = prompt_tokens {
|
||||
span.set_attribute(KeyValue::new("llm.tokens.input", pt as i64));
|
||||
}
|
||||
if let Some(ct) = completion_tokens {
|
||||
span.set_attribute(KeyValue::new("llm.tokens.output", ct as i64));
|
||||
}
|
||||
span.set_status(Status::Ok);
|
||||
yield Ok(LlmStreamEvent::Done {
|
||||
message,
|
||||
prompt_eval_count: prompt_tokens,
|
||||
@@ -770,7 +832,8 @@ impl LlmClient for LlamaCppClient {
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
async fn generate_embeddings(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
|
||||
async fn generate_embeddings(&self, cx: &Context, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
|
||||
let mut span = self.start_request_span(cx, "embeddings", &self.embedding_model);
|
||||
// Deliberately NO GPU lease: the embed slot sits in llama-swap's
|
||||
// always-resident group and never participates in a model swap, so
|
||||
// leasing here would only stall searches behind a queued synthesis.
|
||||
@@ -780,19 +843,27 @@ impl LlmClient for LlamaCppClient {
|
||||
"input": texts,
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("POST {} failed", url))?;
|
||||
let mut request = self.client.post(&url).json(&body);
|
||||
request = crate::otel::with_traceparent(request, span.span_context());
|
||||
let resp = match request.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
span.set_status(Status::error("llama-swap embedding request failed"));
|
||||
return Err(e).with_context(|| format!("POST {} failed", url));
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
crate::otel::record_http_failure(
|
||||
Some(&mut span),
|
||||
status.as_u16(),
|
||||
&format!("llama-swap embedding request failed: {status}"),
|
||||
);
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
bail!("llama-swap embedding request failed: {} — {}", status, body);
|
||||
}
|
||||
crate::otel::set_http_status(Some(&mut span), resp.status().as_u16());
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EmbedResponse {
|
||||
@@ -803,11 +874,20 @@ impl LlmClient for LlamaCppClient {
|
||||
embedding: Vec<f32>,
|
||||
}
|
||||
|
||||
let parsed: EmbedResponse = resp.json().await.context("parsing embed response")?;
|
||||
let parsed: EmbedResponse = match resp.json().await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
span.set_status(Status::error("parsing embed response failed"));
|
||||
return Err(e).context("parsing embed response");
|
||||
}
|
||||
};
|
||||
span.set_status(Status::Ok);
|
||||
Ok(parsed.data.into_iter().map(|i| i.embedding).collect())
|
||||
}
|
||||
|
||||
async fn describe_image(&self, image_base64: &str) -> Result<String> {
|
||||
async fn describe_image(&self, cx: &Context, image_base64: &str) -> Result<String> {
|
||||
// Own span with operation=describe_image — routes through the vision slot.
|
||||
let mut span = self.start_request_span(cx, "describe_image", &self.vision_model);
|
||||
let prompt = "Briefly describe what you see in this image in 1-2 sentences. \
|
||||
Focus on the people, location, and activity.";
|
||||
let system = "You are a scene description assistant. Be concise and factual.";
|
||||
@@ -823,7 +903,12 @@ impl LlmClient for LlamaCppClient {
|
||||
];
|
||||
|
||||
let (reply, _, _) = self
|
||||
.chat_completion_with_model(&self.vision_model.clone(), messages, Vec::new())
|
||||
.chat_completion_with_model(
|
||||
&self.vision_model.clone(),
|
||||
Some(&mut span),
|
||||
messages,
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
Ok(reply.content)
|
||||
}
|
||||
|
||||
+12
-2
@@ -1,6 +1,7 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use futures::stream::BoxStream;
|
||||
use opentelemetry::Context;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Provider-agnostic surface for LLM backends (Ollama, OpenRouter, …).
|
||||
@@ -10,6 +11,12 @@ use serde::{Deserialize, Serialize};
|
||||
/// when a provider requires it (OpenAI-compatible APIs do), and `images`
|
||||
/// stays as base64 strings here and is rewritten into content-parts where
|
||||
/// needed.
|
||||
///
|
||||
/// Every method that performs an HTTP call takes the caller's tracing
|
||||
/// `Context` as its first parameter so the impl can start an `llm.request`
|
||||
/// span parented under the caller's feature span — the codebase threads
|
||||
/// contexts explicitly rather than relying on ambient `Context::current()`
|
||||
/// at deep call sites.
|
||||
// First consumer lands in a later PR (OpenRouter impl + hybrid mode routing).
|
||||
#[allow(dead_code)]
|
||||
#[async_trait]
|
||||
@@ -18,6 +25,7 @@ pub trait LlmClient: Send + Sync {
|
||||
/// base64 images (ignored by providers without vision support).
|
||||
async fn generate(
|
||||
&self,
|
||||
cx: &Context,
|
||||
prompt: &str,
|
||||
system: Option<&str>,
|
||||
images: Option<Vec<String>>,
|
||||
@@ -27,6 +35,7 @@ pub trait LlmClient: Send + Sync {
|
||||
/// (which may contain tool_calls) plus optional prompt/eval token counts.
|
||||
async fn chat_with_tools(
|
||||
&self,
|
||||
cx: &Context,
|
||||
messages: Vec<ChatMessage>,
|
||||
tools: Vec<Tool>,
|
||||
) -> Result<(ChatMessage, Option<i32>, Option<i32>)>;
|
||||
@@ -39,16 +48,17 @@ pub trait LlmClient: Send + Sync {
|
||||
/// as one `Done` event.
|
||||
async fn chat_with_tools_stream(
|
||||
&self,
|
||||
cx: &Context,
|
||||
messages: Vec<ChatMessage>,
|
||||
tools: Vec<Tool>,
|
||||
) -> Result<BoxStream<'static, Result<LlmStreamEvent>>>;
|
||||
|
||||
/// Batch embedding generation. Dimensionality is provider/model specific.
|
||||
async fn generate_embeddings(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>>;
|
||||
async fn generate_embeddings(&self, cx: &Context, texts: &[&str]) -> Result<Vec<Vec<f32>>>;
|
||||
|
||||
/// One-shot vision description of an image. Used to convert images into
|
||||
/// plain text for the hybrid-mode conversation flow.
|
||||
async fn describe_image(&self, image_base64: &str) -> Result<String>;
|
||||
async fn describe_image(&self, cx: &Context, image_base64: &str) -> Result<String>;
|
||||
|
||||
/// Enumerate available models with their capabilities.
|
||||
async fn list_models(&self) -> Result<Vec<ModelCapabilities>>;
|
||||
|
||||
+25
-4
@@ -47,12 +47,24 @@ impl LocalLlm {
|
||||
/// pick query vs document — retrieval models treat the two sides
|
||||
/// differently and an unmarked embed invites prefix-mismatch bugs.
|
||||
pub async fn embed_query(&self, text: &str) -> Result<Vec<f32>> {
|
||||
super::embed_query(&self.ollama, self.llamacpp.as_deref(), text).await
|
||||
super::embed_query(
|
||||
&opentelemetry::Context::new(),
|
||||
&self.ollama,
|
||||
self.llamacpp.as_deref(),
|
||||
text,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Embed corpus text (applies `EMBED_DOCUMENT_PREFIX`).
|
||||
pub async fn embed_document(&self, text: &str) -> Result<Vec<f32>> {
|
||||
super::embed_document(&self.ollama, self.llamacpp.as_deref(), text).await
|
||||
super::embed_document(
|
||||
&opentelemetry::Context::new(),
|
||||
&self.ollama,
|
||||
self.llamacpp.as_deref(),
|
||||
text,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Single-shot local text generation via the `LLM_BACKEND`-selected
|
||||
@@ -60,14 +72,23 @@ impl LocalLlm {
|
||||
pub async fn generate(&self, prompt: &str, system: Option<&str>) -> Result<String> {
|
||||
if super::local_backend_is_llamacpp() {
|
||||
if let Some(lc) = self.llamacpp.as_deref() {
|
||||
return <LlamaCppClient as LlmClient>::generate(lc, prompt, system, None).await;
|
||||
return <LlamaCppClient as LlmClient>::generate(
|
||||
lc,
|
||||
&opentelemetry::Context::new(),
|
||||
prompt,
|
||||
system,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
anyhow::bail!(
|
||||
"LLM_BACKEND=llamacpp but LlamaCppClient is unconfigured — \
|
||||
set LLAMA_SWAP_URL or switch to LLM_BACKEND=ollama"
|
||||
);
|
||||
}
|
||||
self.ollama.generate(prompt, system).await
|
||||
self.ollama
|
||||
.generate(&opentelemetry::Context::new(), prompt, system)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Label identifying which backend + model produces embeddings right
|
||||
|
||||
+9
-5
@@ -115,24 +115,26 @@ fn embed_prefix(key: &str) -> String {
|
||||
/// `Instruct: <task>\nQuery: `. Must pair with the document prefix the
|
||||
/// corpus was embedded with or similarity degrades.
|
||||
pub async fn embed_query(
|
||||
cx: &opentelemetry::Context,
|
||||
ollama: &OllamaClient,
|
||||
llamacpp: Option<&LlamaCppClient>,
|
||||
text: &str,
|
||||
) -> anyhow::Result<Vec<f32>> {
|
||||
let prefixed = format!("{}{}", embed_prefix("EMBED_QUERY_PREFIX"), text);
|
||||
embed_one(ollama, llamacpp, &prefixed).await
|
||||
embed_one(cx, ollama, llamacpp, &prefixed).await
|
||||
}
|
||||
|
||||
/// Embed corpus text (the stored side of retrieval). Applies
|
||||
/// `EMBED_DOCUMENT_PREFIX` (default empty; nomic v1.5 wants
|
||||
/// `search_document: `, Qwen3-Embedding wants none).
|
||||
pub async fn embed_document(
|
||||
cx: &opentelemetry::Context,
|
||||
ollama: &OllamaClient,
|
||||
llamacpp: Option<&LlamaCppClient>,
|
||||
text: &str,
|
||||
) -> anyhow::Result<Vec<f32>> {
|
||||
let prefixed = format!("{}{}", embed_prefix("EMBED_DOCUMENT_PREFIX"), text);
|
||||
embed_one(ollama, llamacpp, &prefixed).await
|
||||
embed_one(cx, ollama, llamacpp, &prefixed).await
|
||||
}
|
||||
|
||||
/// Embed a batch of strings via the configured local backend. Routes
|
||||
@@ -140,30 +142,32 @@ pub async fn embed_document(
|
||||
/// configured), else Ollama. See [`local_backend_is_llamacpp`] for the
|
||||
/// rationale on consistency.
|
||||
pub async fn embed_many(
|
||||
cx: &opentelemetry::Context,
|
||||
ollama: &OllamaClient,
|
||||
llamacpp: Option<&LlamaCppClient>,
|
||||
texts: &[&str],
|
||||
) -> anyhow::Result<Vec<Vec<f32>>> {
|
||||
if local_backend_is_llamacpp() {
|
||||
if let Some(lc) = llamacpp {
|
||||
return <LlamaCppClient as LlmClient>::generate_embeddings(lc, texts).await;
|
||||
return <LlamaCppClient as LlmClient>::generate_embeddings(lc, cx, texts).await;
|
||||
}
|
||||
anyhow::bail!(
|
||||
"LLM_BACKEND=llamacpp but LlamaCppClient is unconfigured — \
|
||||
set LLAMA_SWAP_URL or switch to LLM_BACKEND=ollama"
|
||||
);
|
||||
}
|
||||
ollama.generate_embeddings(texts).await
|
||||
ollama.generate_embeddings(cx, texts).await
|
||||
}
|
||||
|
||||
/// Embed one string via the configured local backend. Single-text
|
||||
/// convenience over [`embed_many`].
|
||||
pub async fn embed_one(
|
||||
cx: &opentelemetry::Context,
|
||||
ollama: &OllamaClient,
|
||||
llamacpp: Option<&LlamaCppClient>,
|
||||
text: &str,
|
||||
) -> anyhow::Result<Vec<f32>> {
|
||||
let mut vecs = embed_many(ollama, llamacpp, &[text]).await?;
|
||||
let mut vecs = embed_many(cx, ollama, llamacpp, &[text]).await?;
|
||||
vecs.pop()
|
||||
.ok_or_else(|| anyhow::anyhow!("embedding backend returned no embeddings"))
|
||||
}
|
||||
|
||||
+4
-1
@@ -259,10 +259,13 @@ pub async fn translate_nl_query(
|
||||
nl: &str,
|
||||
tag_vocab: &[(i32, String)],
|
||||
today: chrono::NaiveDate,
|
||||
cx: &opentelemetry::Context,
|
||||
) -> Result<StructuredQuery> {
|
||||
let system = build_system_prompt(tag_vocab, today);
|
||||
let messages = vec![ChatMessage::system(system), ChatMessage::user(nl)];
|
||||
let (msg, _, _) = client.chat_with_tools(messages, Vec::<Tool>::new()).await?;
|
||||
let (msg, _, _) = client
|
||||
.chat_with_tools(cx, messages, Vec::<Tool>::new())
|
||||
.await?;
|
||||
parse_response(&msg.content, tag_vocab)
|
||||
}
|
||||
|
||||
|
||||
+238
-55
@@ -1,6 +1,9 @@
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::Context as _;
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDate;
|
||||
use opentelemetry::trace::{Span, Status};
|
||||
use opentelemetry::{Context, KeyValue};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -365,6 +368,7 @@ impl OllamaClient {
|
||||
|
||||
async fn try_generate(
|
||||
&self,
|
||||
mut span: Option<&mut opentelemetry::global::BoxedSpan>,
|
||||
url: &str,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
@@ -382,15 +386,30 @@ impl OllamaClient {
|
||||
think,
|
||||
};
|
||||
|
||||
let response = self
|
||||
let mut request_builder = self
|
||||
.client
|
||||
.post(format!("{}/api/generate", url))
|
||||
.json(&request)
|
||||
.send()
|
||||
.await?;
|
||||
.json(&request);
|
||||
if let Some(s) = span.as_mut() {
|
||||
request_builder = crate::otel::with_traceparent(request_builder, s.span_context());
|
||||
}
|
||||
let response = match request_builder.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
if let Some(s) = span.as_mut() {
|
||||
s.set_status(Status::error("Ollama request failed"));
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
crate::otel::record_http_failure(
|
||||
span.as_deref_mut(),
|
||||
status.as_u16(),
|
||||
&format!("Ollama request failed: {status}"),
|
||||
);
|
||||
let error_body = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!(
|
||||
"Ollama request failed: {} - {}",
|
||||
@@ -398,8 +417,26 @@ impl OllamaClient {
|
||||
error_body
|
||||
));
|
||||
}
|
||||
crate::otel::set_http_status(span.as_deref_mut(), response.status().as_u16());
|
||||
|
||||
let result: OllamaResponse = response.json().await?;
|
||||
let result: OllamaResponse = match response.json().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
if let Some(s) = span.as_mut() {
|
||||
s.set_status(Status::error("parsing Ollama response failed"));
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
if let Some(s) = span.as_mut() {
|
||||
if let Some(pt) = result.prompt_eval_count {
|
||||
s.set_attribute(KeyValue::new("llm.tokens.input", pt as i64));
|
||||
}
|
||||
if let Some(ct) = result.eval_count {
|
||||
s.set_attribute(KeyValue::new("llm.tokens.output", ct as i64));
|
||||
}
|
||||
s.set_status(Status::Ok);
|
||||
}
|
||||
log_chat_metrics(
|
||||
result.prompt_eval_count,
|
||||
result.prompt_eval_duration,
|
||||
@@ -409,32 +446,55 @@ impl OllamaClient {
|
||||
Ok(result.response)
|
||||
}
|
||||
|
||||
pub async fn generate(&self, prompt: &str, system: Option<&str>) -> Result<String> {
|
||||
self.generate_with_images(prompt, system, None).await
|
||||
/// Start the `llm.request` span for an outgoing Ollama call.
|
||||
fn start_request_span(
|
||||
&self,
|
||||
cx: &Context,
|
||||
operation: &str,
|
||||
model: &str,
|
||||
) -> opentelemetry::global::BoxedSpan {
|
||||
crate::otel::start_llm_request_span(cx, "ollama", operation, model)
|
||||
}
|
||||
|
||||
pub async fn generate(
|
||||
&self,
|
||||
cx: &Context,
|
||||
prompt: &str,
|
||||
system: Option<&str>,
|
||||
) -> Result<String> {
|
||||
self.generate_with_images(cx, prompt, system, None).await
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn generate_no_think(&self, prompt: &str, system: Option<&str>) -> Result<String> {
|
||||
self.generate_with_options(prompt, system, None, Some(false))
|
||||
pub async fn generate_no_think(
|
||||
&self,
|
||||
cx: &Context,
|
||||
prompt: &str,
|
||||
system: Option<&str>,
|
||||
) -> Result<String> {
|
||||
self.generate_with_options(cx, prompt, system, None, Some(false), "generate")
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn generate_with_images(
|
||||
&self,
|
||||
cx: &Context,
|
||||
prompt: &str,
|
||||
system: Option<&str>,
|
||||
images: Option<Vec<String>>,
|
||||
) -> Result<String> {
|
||||
self.generate_with_options(prompt, system, images, None)
|
||||
self.generate_with_options(cx, prompt, system, images, None, "generate")
|
||||
.await
|
||||
}
|
||||
|
||||
async fn generate_with_options(
|
||||
&self,
|
||||
cx: &Context,
|
||||
prompt: &str,
|
||||
system: Option<&str>,
|
||||
images: Option<Vec<String>>,
|
||||
think: Option<bool>,
|
||||
operation: &str,
|
||||
) -> Result<String> {
|
||||
log::debug!("=== Ollama Request ===");
|
||||
log::debug!("Primary model: {}", self.primary_model);
|
||||
@@ -447,6 +507,10 @@ impl OllamaClient {
|
||||
}
|
||||
log::debug!("=====================");
|
||||
|
||||
// Span for the whole generate attempt; the model attribute
|
||||
// reflects whichever server actually answered.
|
||||
let mut span = self.start_request_span(cx, operation, &self.primary_model);
|
||||
|
||||
// Try primary server first with primary model
|
||||
log::info!(
|
||||
"Attempting to generate with primary server: {} (model: {})",
|
||||
@@ -455,6 +519,7 @@ impl OllamaClient {
|
||||
);
|
||||
let primary_result = self
|
||||
.try_generate(
|
||||
Some(&mut span),
|
||||
&self.primary_url,
|
||||
&self.primary_model,
|
||||
prompt,
|
||||
@@ -483,8 +548,10 @@ impl OllamaClient {
|
||||
fallback_url,
|
||||
fallback_model
|
||||
);
|
||||
span.set_attribute(KeyValue::new("llm.model", fallback_model.to_string()));
|
||||
match self
|
||||
.try_generate(
|
||||
Some(&mut span),
|
||||
fallback_url,
|
||||
fallback_model,
|
||||
prompt,
|
||||
@@ -531,6 +598,7 @@ impl OllamaClient {
|
||||
/// Generate a title for a single photo based on its generated summary
|
||||
pub async fn generate_photo_title(
|
||||
&self,
|
||||
cx: &Context,
|
||||
summary: &str,
|
||||
custom_system: Option<&str>,
|
||||
) -> Result<String> {
|
||||
@@ -546,7 +614,7 @@ Capture the key moment or theme. Return ONLY the title, nothing else."#,
|
||||
let system = custom_system.unwrap_or("You are my long term memory assistant. Use only the information provided. Do not invent details.");
|
||||
|
||||
let title = self
|
||||
.generate_with_images(&prompt, Some(system), None)
|
||||
.generate_with_images(cx, &prompt, Some(system), None)
|
||||
.await?;
|
||||
// Models decorate despite "Return ONLY the title": quotes, bold
|
||||
// markers, sometimes a "Title:" label.
|
||||
@@ -563,6 +631,7 @@ Capture the key moment or theme. Return ONLY the title, nothing else."#,
|
||||
/// Generate a summary for a single photo based on its context
|
||||
pub async fn generate_photo_summary(
|
||||
&self,
|
||||
cx: &Context,
|
||||
date: NaiveDate,
|
||||
location: Option<&str>,
|
||||
contact: Option<&str>,
|
||||
@@ -645,21 +714,32 @@ Analyze the image and use specific details from both the visual content and the
|
||||
let system = custom_system.unwrap_or("You are a memory refreshing assistant who is able to provide insights through analyzing past conversations. Use only the information provided. Do not invent details.");
|
||||
|
||||
let images = image_base64.map(|img| vec![img]);
|
||||
self.generate_with_images(&prompt, Some(system), images)
|
||||
self.generate_with_images(cx, &prompt, Some(system), images)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Generate a brief visual description of a photo for use in RAG query enrichment.
|
||||
/// Returns 1-2 sentences describing people, location, and activity visible in the image.
|
||||
/// Only called when the model has vision capabilities.
|
||||
pub async fn generate_photo_description(&self, image_base64: &str) -> Result<String> {
|
||||
pub async fn generate_photo_description(
|
||||
&self,
|
||||
cx: &Context,
|
||||
image_base64: &str,
|
||||
) -> Result<String> {
|
||||
let prompt = "Briefly describe what you see in this image in 1-2 sentences. \
|
||||
Focus on the people, location, and activity.";
|
||||
let system = "You are a scene description assistant. Be concise and factual.";
|
||||
let images = vec![image_base64.to_string()];
|
||||
|
||||
let description = self
|
||||
.generate_with_images(prompt, Some(system), Some(images))
|
||||
.generate_with_options(
|
||||
cx,
|
||||
prompt,
|
||||
Some(system),
|
||||
Some(images),
|
||||
None,
|
||||
"describe_image",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(description.trim().to_string())
|
||||
@@ -672,9 +752,11 @@ Analyze the image and use specific details from both the visual content and the
|
||||
/// iteration of the agent loop.
|
||||
pub async fn chat_with_tools(
|
||||
&self,
|
||||
cx: &Context,
|
||||
messages: Vec<ChatMessage>,
|
||||
tools: Vec<Tool>,
|
||||
) -> Result<(ChatMessage, Option<i32>, Option<i32>)> {
|
||||
let mut span = self.start_request_span(cx, "chat", &self.primary_model);
|
||||
let order = self.attempt_order();
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
|
||||
@@ -685,8 +767,11 @@ Analyze the image and use specific details from both the visual content and the
|
||||
url,
|
||||
model
|
||||
);
|
||||
if *label == "fallback" {
|
||||
span.set_attribute(KeyValue::new("llm.model", model.to_string()));
|
||||
}
|
||||
match self
|
||||
.try_chat_with_tools(url, messages.clone(), tools.clone())
|
||||
.try_chat_with_tools(Some(&mut span), url, messages.clone(), tools.clone())
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
@@ -728,18 +813,22 @@ Analyze the image and use specific details from both the visual content and the
|
||||
/// any, live on the final message).
|
||||
pub async fn chat_with_tools_stream(
|
||||
&self,
|
||||
cx: &Context,
|
||||
messages: Vec<ChatMessage>,
|
||||
tools: Vec<Tool>,
|
||||
) -> Result<BoxStream<'static, Result<LlmStreamEvent>>> {
|
||||
// Same preference logic as `chat_with_tools`. Only the initial
|
||||
// connection is retried across servers — once the stream begins,
|
||||
// mid-stream errors propagate to the caller.
|
||||
// mid-stream errors propagate to the caller. Each attempt gets its
|
||||
// own `llm.request` span, created inside
|
||||
// `try_chat_with_tools_stream` so the winning attempt's span moves
|
||||
// into the stream and is finalized when the response drains.
|
||||
let order = self.attempt_order();
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
|
||||
for (label, url, _model) in &order {
|
||||
match self
|
||||
.try_chat_with_tools_stream(url, messages.clone(), tools.clone())
|
||||
.try_chat_with_tools_stream(cx, url, messages.clone(), tools.clone())
|
||||
.await
|
||||
{
|
||||
Ok(s) => {
|
||||
@@ -759,6 +848,7 @@ Analyze the image and use specific details from both the visual content and the
|
||||
|
||||
async fn try_chat_with_tools_stream(
|
||||
&self,
|
||||
cx: &Context,
|
||||
base_url: &str,
|
||||
messages: Vec<ChatMessage>,
|
||||
tools: Vec<Tool>,
|
||||
@@ -773,6 +863,11 @@ Analyze the image and use specific details from both the visual content and the
|
||||
};
|
||||
let options = self.build_options();
|
||||
|
||||
// The span outlives this method — the stream yields until the
|
||||
// response fully drains, so the owned span moves into the
|
||||
// generator and is finalized there.
|
||||
let mut span = self.start_request_span(cx, "chat_stream", model);
|
||||
|
||||
let request_body = OllamaChatRequest {
|
||||
model,
|
||||
messages: &messages,
|
||||
@@ -781,16 +876,26 @@ Analyze the image and use specific details from both the visual content and the
|
||||
options,
|
||||
};
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to connect to Ollama at {}", url))?;
|
||||
let response = {
|
||||
let mut rb = self.client.post(&url).json(&request_body);
|
||||
rb = crate::otel::with_traceparent(rb, span.span_context());
|
||||
match rb.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
span.set_status(Status::error("Ollama stream connect failed"));
|
||||
return Err(e)
|
||||
.with_context(|| format!("Failed to connect to Ollama at {}", url));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
crate::otel::record_http_failure(
|
||||
Some(&mut span),
|
||||
status.as_u16(),
|
||||
&format!("Ollama stream request failed: {status}"),
|
||||
);
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!(
|
||||
"Ollama stream request failed with status {}: {}",
|
||||
@@ -798,6 +903,7 @@ Analyze the image and use specific details from both the visual content and the
|
||||
body
|
||||
);
|
||||
}
|
||||
crate::otel::set_http_status(Some(&mut span), response.status().as_u16());
|
||||
|
||||
// Ollama streams NDJSON: each line is a full `OllamaStreamChunk`.
|
||||
// We buffer partial lines across chunks from the byte stream.
|
||||
@@ -879,6 +985,13 @@ Analyze the image and use specific details from both the visual content and the
|
||||
eval_count,
|
||||
eval_duration,
|
||||
);
|
||||
if let Some(pt) = prompt_eval_count {
|
||||
span.set_attribute(KeyValue::new("llm.tokens.input", pt as i64));
|
||||
}
|
||||
if let Some(ct) = eval_count {
|
||||
span.set_attribute(KeyValue::new("llm.tokens.output", ct as i64));
|
||||
}
|
||||
span.set_status(Status::Ok);
|
||||
let message = ChatMessage {
|
||||
role,
|
||||
content: accumulated,
|
||||
@@ -897,6 +1010,7 @@ Analyze the image and use specific details from both the visual content and the
|
||||
|
||||
async fn try_chat_with_tools(
|
||||
&self,
|
||||
mut span: Option<&mut opentelemetry::global::BoxedSpan>,
|
||||
base_url: &str,
|
||||
messages: Vec<ChatMessage>,
|
||||
tools: Vec<Tool>,
|
||||
@@ -924,16 +1038,30 @@ Analyze the image and use specific details from both the visual content and the
|
||||
.unwrap_or_else(|e| format!("<serialization error: {}>", e));
|
||||
log::debug!("chat_with_tools request body: {}", request_json);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to connect to Ollama at {}", url))?;
|
||||
let response = {
|
||||
let mut rb = self.client.post(&url).json(&request_body);
|
||||
if let Some(s) = span.as_mut() {
|
||||
rb = crate::otel::with_traceparent(rb, s.span_context());
|
||||
}
|
||||
match rb.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
if let Some(s) = span.as_mut() {
|
||||
s.set_status(Status::error("Ollama chat connect failed"));
|
||||
}
|
||||
return Err(e)
|
||||
.with_context(|| format!("Failed to connect to Ollama at {}", url));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
crate::otel::record_http_failure(
|
||||
span.as_deref_mut(),
|
||||
status.as_u16(),
|
||||
&format!("Ollama chat request failed: {status}"),
|
||||
);
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
// warn, not error — the outer `chat_with_tools` may recover via
|
||||
// the fallback server. When both fail, the outer layer emits the
|
||||
@@ -950,11 +1078,26 @@ Analyze the image and use specific details from both the visual content and the
|
||||
body
|
||||
);
|
||||
}
|
||||
crate::otel::set_http_status(span.as_deref_mut(), response.status().as_u16());
|
||||
|
||||
let chat_response: OllamaChatResponse = response
|
||||
.json()
|
||||
.await
|
||||
.with_context(|| "Failed to parse Ollama chat response")?;
|
||||
let chat_response: OllamaChatResponse = match response.json().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
if let Some(s) = span.as_mut() {
|
||||
s.set_status(Status::error("parsing Ollama chat response failed"));
|
||||
}
|
||||
return Err(e).with_context(|| "Failed to parse Ollama chat response");
|
||||
}
|
||||
};
|
||||
if let Some(s) = span.as_mut() {
|
||||
if let Some(pt) = chat_response.prompt_eval_count {
|
||||
s.set_attribute(KeyValue::new("llm.tokens.input", pt as i64));
|
||||
}
|
||||
if let Some(ct) = chat_response.eval_count {
|
||||
s.set_attribute(KeyValue::new("llm.tokens.output", ct as i64));
|
||||
}
|
||||
s.set_status(Status::Ok);
|
||||
}
|
||||
|
||||
// Log performance counters returned by Ollama. Durations are
|
||||
// reported in nanoseconds; we render ms + tokens/sec for skim-ability
|
||||
@@ -976,8 +1119,8 @@ Analyze the image and use specific details from both the visual content and the
|
||||
|
||||
/// Generate an embedding vector for text using nomic-embed-text:v1.5
|
||||
/// Returns a 768-dimensional vector as Vec<f32>
|
||||
pub async fn generate_embedding(&self, text: &str) -> Result<Vec<f32>> {
|
||||
let embeddings = self.generate_embeddings(&[text]).await?;
|
||||
pub async fn generate_embedding(&self, cx: &Context, text: &str) -> Result<Vec<f32>> {
|
||||
let embeddings = self.generate_embeddings(cx, &[text]).await?;
|
||||
embeddings
|
||||
.into_iter()
|
||||
.next()
|
||||
@@ -987,9 +1130,11 @@ Analyze the image and use specific details from both the visual content and the
|
||||
/// Generate embeddings for multiple texts in a single API call (batch mode)
|
||||
/// Returns a vector of 768-dimensional vectors
|
||||
/// This is much more efficient than calling generate_embedding multiple times
|
||||
pub async fn generate_embeddings(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
|
||||
pub async fn generate_embeddings(&self, cx: &Context, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
|
||||
let embedding_model = EMBEDDING_MODEL;
|
||||
|
||||
let mut span = self.start_request_span(cx, "embeddings", embedding_model);
|
||||
|
||||
log::debug!("=== Ollama Batch Embedding Request ===");
|
||||
log::debug!("Model: {}", embedding_model);
|
||||
log::debug!("Batch size: {} texts", texts.len());
|
||||
@@ -1003,7 +1148,7 @@ Analyze the image and use specific details from both the visual content and the
|
||||
embedding_model
|
||||
);
|
||||
let primary_result = self
|
||||
.try_generate_embeddings(&self.primary_url, embedding_model, texts)
|
||||
.try_generate_embeddings(Some(&mut span), &self.primary_url, embedding_model, texts)
|
||||
.await;
|
||||
|
||||
let embeddings = match primary_result {
|
||||
@@ -1026,7 +1171,12 @@ Analyze the image and use specific details from both the visual content and the
|
||||
embedding_model
|
||||
);
|
||||
match self
|
||||
.try_generate_embeddings(fallback_url, embedding_model, texts)
|
||||
.try_generate_embeddings(
|
||||
Some(&mut span),
|
||||
fallback_url,
|
||||
embedding_model,
|
||||
texts,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(embeddings) => {
|
||||
@@ -1073,6 +1223,7 @@ Analyze the image and use specific details from both the visual content and the
|
||||
/// Internal helper to try generating embeddings for multiple texts from a specific server
|
||||
async fn try_generate_embeddings(
|
||||
&self,
|
||||
mut span: Option<&mut opentelemetry::global::BoxedSpan>,
|
||||
url: &str,
|
||||
model: &str,
|
||||
texts: &[&str],
|
||||
@@ -1082,15 +1233,32 @@ Analyze the image and use specific details from both the visual content and the
|
||||
input: texts.iter().map(|s| s.to_string()).collect(),
|
||||
};
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(format!("{}/api/embed", url))
|
||||
.json(&request)
|
||||
.send()
|
||||
.await?;
|
||||
let response = {
|
||||
let mut rb = self
|
||||
.client
|
||||
.post(format!("{}/api/embed", url))
|
||||
.json(&request);
|
||||
if let Some(s) = span.as_mut() {
|
||||
rb = crate::otel::with_traceparent(rb, s.span_context());
|
||||
}
|
||||
match rb.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
if let Some(s) = span.as_mut() {
|
||||
s.set_status(Status::error("Ollama embedding request failed"));
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
crate::otel::record_http_failure(
|
||||
span.as_deref_mut(),
|
||||
status.as_u16(),
|
||||
&format!("Ollama batch embedding request failed: {status}"),
|
||||
);
|
||||
let error_body = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!(
|
||||
"Ollama batch embedding request failed: {} - {}",
|
||||
@@ -1098,8 +1266,20 @@ Analyze the image and use specific details from both the visual content and the
|
||||
error_body
|
||||
));
|
||||
}
|
||||
crate::otel::set_http_status(span.as_deref_mut(), response.status().as_u16());
|
||||
|
||||
let result: OllamaEmbedResponse = response.json().await?;
|
||||
let result: OllamaEmbedResponse = match response.json().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
if let Some(s) = span.as_mut() {
|
||||
s.set_status(Status::error("parsing Ollama embed response failed"));
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
if let Some(s) = span.as_mut() {
|
||||
s.set_status(Status::Ok);
|
||||
}
|
||||
Ok(result.embeddings)
|
||||
}
|
||||
}
|
||||
@@ -1108,35 +1288,38 @@ Analyze the image and use specific details from both the visual content and the
|
||||
impl LlmClient for OllamaClient {
|
||||
async fn generate(
|
||||
&self,
|
||||
cx: &Context,
|
||||
prompt: &str,
|
||||
system: Option<&str>,
|
||||
images: Option<Vec<String>>,
|
||||
) -> Result<String> {
|
||||
self.generate_with_images(prompt, system, images).await
|
||||
self.generate_with_images(cx, prompt, system, images).await
|
||||
}
|
||||
|
||||
async fn chat_with_tools(
|
||||
&self,
|
||||
cx: &Context,
|
||||
messages: Vec<ChatMessage>,
|
||||
tools: Vec<Tool>,
|
||||
) -> Result<(ChatMessage, Option<i32>, Option<i32>)> {
|
||||
OllamaClient::chat_with_tools(self, messages, tools).await
|
||||
OllamaClient::chat_with_tools(self, cx, messages, tools).await
|
||||
}
|
||||
|
||||
async fn chat_with_tools_stream(
|
||||
&self,
|
||||
cx: &Context,
|
||||
messages: Vec<ChatMessage>,
|
||||
tools: Vec<Tool>,
|
||||
) -> Result<BoxStream<'static, Result<LlmStreamEvent>>> {
|
||||
OllamaClient::chat_with_tools_stream(self, messages, tools).await
|
||||
OllamaClient::chat_with_tools_stream(self, cx, messages, tools).await
|
||||
}
|
||||
|
||||
async fn generate_embeddings(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
|
||||
OllamaClient::generate_embeddings(self, texts).await
|
||||
async fn generate_embeddings(&self, cx: &Context, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
|
||||
OllamaClient::generate_embeddings(self, cx, texts).await
|
||||
}
|
||||
|
||||
async fn describe_image(&self, image_base64: &str) -> Result<String> {
|
||||
self.generate_photo_description(image_base64).await
|
||||
async fn describe_image(&self, cx: &Context, image_base64: &str) -> Result<String> {
|
||||
self.generate_photo_description(cx, image_base64).await
|
||||
}
|
||||
|
||||
async fn list_models(&self) -> Result<Vec<ModelCapabilities>> {
|
||||
|
||||
+202
-83
@@ -2,8 +2,11 @@
|
||||
// the translation helpers directly.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use anyhow::Context as _;
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use async_trait::async_trait;
|
||||
use opentelemetry::trace::{Span, Status};
|
||||
use opentelemetry::{Context, KeyValue};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
@@ -14,6 +17,7 @@ use std::time::{Duration, Instant};
|
||||
use crate::ai::llm_client::{
|
||||
ChatMessage, LlmClient, LlmStreamEvent, ModelCapabilities, Tool, ToolCall, ToolCallFunction,
|
||||
};
|
||||
|
||||
use futures::stream::{BoxStream, StreamExt};
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://openrouter.ai/api/v1";
|
||||
@@ -144,6 +148,19 @@ impl OpenRouterClient {
|
||||
b
|
||||
}
|
||||
|
||||
/// Start the `llm.request` span for an outgoing call. `operation` is
|
||||
/// `chat`, `embeddings`, or `describe` — the call shape, not the entry
|
||||
/// point (a `describe_image` funneled through `chat_with_tools` is
|
||||
/// still a `describe`).
|
||||
fn start_request_span(
|
||||
&self,
|
||||
cx: &Context,
|
||||
operation: &str,
|
||||
model: &str,
|
||||
) -> opentelemetry::global::BoxedSpan {
|
||||
crate::otel::start_llm_request_span(cx, "openrouter", operation, model)
|
||||
}
|
||||
|
||||
/// Translate canonical messages to the OpenAI-compatible wire shape.
|
||||
///
|
||||
/// Walks in order so it can attach `tool_call_id` to `role=tool` messages
|
||||
@@ -312,6 +329,7 @@ impl OpenRouterClient {
|
||||
impl LlmClient for OpenRouterClient {
|
||||
async fn generate(
|
||||
&self,
|
||||
cx: &Context,
|
||||
prompt: &str,
|
||||
system: Option<&str>,
|
||||
images: Option<Vec<String>>,
|
||||
@@ -324,90 +342,34 @@ impl LlmClient for OpenRouterClient {
|
||||
user.images = images;
|
||||
messages.push(user);
|
||||
|
||||
let (reply, _, _) = self.chat_with_tools(messages, Vec::new()).await?;
|
||||
// Delegates to chat_with_tools, which starts the llm.request span
|
||||
// (operation=chat) — one span per HTTP request.
|
||||
let (reply, _, _) = self.chat_with_tools(cx, messages, Vec::new()).await?;
|
||||
Ok(reply.content)
|
||||
}
|
||||
|
||||
async fn chat_with_tools(
|
||||
&self,
|
||||
cx: &Context,
|
||||
messages: Vec<ChatMessage>,
|
||||
tools: Vec<Tool>,
|
||||
) -> Result<(ChatMessage, Option<i32>, Option<i32>)> {
|
||||
let url = format!("{}/chat/completions", self.base_url);
|
||||
let mut body = serde_json::Map::new();
|
||||
body.insert("model".into(), Value::String(self.primary_model.clone()));
|
||||
body.insert(
|
||||
"messages".into(),
|
||||
Value::Array(Self::messages_to_openai(&messages)),
|
||||
);
|
||||
body.insert("stream".into(), Value::Bool(false));
|
||||
if !tools.is_empty() {
|
||||
body.insert(
|
||||
"tools".into(),
|
||||
serde_json::to_value(&tools).context("serializing tools")?,
|
||||
);
|
||||
}
|
||||
for (k, v) in self.build_options() {
|
||||
body.insert(k.into(), v);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"OpenRouter chat_with_tools: model={} messages={} tools={}",
|
||||
self.primary_model,
|
||||
messages.len(),
|
||||
tools.len()
|
||||
);
|
||||
|
||||
let resp = self
|
||||
.authed(self.client.post(&url))
|
||||
.json(&Value::Object(body))
|
||||
.send()
|
||||
let mut span = self.start_request_span(cx, "chat", &self.primary_model);
|
||||
self.chat_with_tools_impl(Some(&mut span), messages, tools)
|
||||
.await
|
||||
.with_context(|| format!("POST {} failed", url))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
bail!("OpenRouter chat request failed: {} — {}", status, body);
|
||||
}
|
||||
|
||||
let parsed: Value = resp.json().await.context("parsing chat response")?;
|
||||
let choice = parsed
|
||||
.get("choices")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"response missing choices[0]: {}",
|
||||
extract_openrouter_error_detail(&parsed)
|
||||
)
|
||||
})?;
|
||||
let msg = choice.get("message").ok_or_else(|| {
|
||||
anyhow!(
|
||||
"choices[0] missing message: {}",
|
||||
extract_openrouter_error_detail(&parsed)
|
||||
)
|
||||
})?;
|
||||
let chat_msg = Self::openai_message_to_chat(msg)?;
|
||||
|
||||
let usage = parsed.get("usage");
|
||||
let prompt_tokens = usage
|
||||
.and_then(|u| u.get("prompt_tokens"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|n| n as i32);
|
||||
let completion_tokens = usage
|
||||
.and_then(|u| u.get("completion_tokens"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|n| n as i32);
|
||||
|
||||
Ok((chat_msg, prompt_tokens, completion_tokens))
|
||||
}
|
||||
|
||||
async fn chat_with_tools_stream(
|
||||
&self,
|
||||
cx: &Context,
|
||||
messages: Vec<ChatMessage>,
|
||||
tools: Vec<Tool>,
|
||||
) -> Result<BoxStream<'static, Result<LlmStreamEvent>>> {
|
||||
// The span outlives this method — the stream yields until the
|
||||
// response fully drains, so the owned span moves into the
|
||||
// generator and is finalized there.
|
||||
let mut span = self.start_request_span(cx, "chat_stream", &self.primary_model);
|
||||
|
||||
let url = format!("{}/chat/completions", self.base_url);
|
||||
let mut body = serde_json::Map::new();
|
||||
body.insert("model".into(), Value::String(self.primary_model.clone()));
|
||||
@@ -432,18 +394,26 @@ impl LlmClient for OpenRouterClient {
|
||||
body.insert(k.into(), v);
|
||||
}
|
||||
|
||||
let resp = self
|
||||
let mut request = self
|
||||
.authed(self.client.post(&url))
|
||||
.json(&Value::Object(body))
|
||||
.json(&Value::Object(body));
|
||||
request = crate::otel::with_traceparent(request, span.span_context());
|
||||
let resp = request
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("POST {} failed", url))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
crate::otel::record_http_failure(
|
||||
Some(&mut span),
|
||||
status.as_u16(),
|
||||
&format!("OpenRouter stream request failed: {status}"),
|
||||
);
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
bail!("OpenRouter stream request failed: {} — {}", status, body);
|
||||
}
|
||||
crate::otel::set_http_status(Some(&mut span), resp.status().as_u16());
|
||||
|
||||
// OpenAI-compat SSE stream. Each event is `data: <json>\n\n`, with
|
||||
// `data: [DONE]` signalling completion. Tool calls arrive as
|
||||
@@ -467,6 +437,7 @@ impl LlmClient for OpenRouterClient {
|
||||
let chunk = match chunk {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
span.set_status(Status::error(format!("stream read failed: {e}")));
|
||||
yield Err(anyhow!("stream read failed: {}", e));
|
||||
return;
|
||||
}
|
||||
@@ -517,6 +488,9 @@ impl LlmClient for OpenRouterClient {
|
||||
.get("completion_tokens")
|
||||
.and_then(|n| n.as_i64())
|
||||
.map(|n| n as i32);
|
||||
if let Some(cost) = usage.get("cost").and_then(|c| c.as_f64()) {
|
||||
span.set_attribute(KeyValue::new("llm.cost_usd", cost));
|
||||
}
|
||||
}
|
||||
|
||||
let Some(choices) = v.get("choices").and_then(|c| c.as_array())
|
||||
@@ -606,6 +580,13 @@ impl LlmClient for OpenRouterClient {
|
||||
tool_calls,
|
||||
images: None,
|
||||
};
|
||||
if let Some(pt) = prompt_tokens {
|
||||
span.set_attribute(KeyValue::new("llm.tokens.input", pt as i64));
|
||||
}
|
||||
if let Some(ct) = completion_tokens {
|
||||
span.set_attribute(KeyValue::new("llm.tokens.output", ct as i64));
|
||||
}
|
||||
span.set_status(Status::Ok);
|
||||
yield Ok(LlmStreamEvent::Done {
|
||||
message,
|
||||
prompt_eval_count: prompt_tokens,
|
||||
@@ -616,25 +597,35 @@ impl LlmClient for OpenRouterClient {
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
async fn generate_embeddings(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
|
||||
async fn generate_embeddings(&self, cx: &Context, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
|
||||
let mut span = self.start_request_span(cx, "embeddings", &self.embedding_model);
|
||||
let url = format!("{}/embeddings", self.base_url);
|
||||
let body = json!({
|
||||
"model": self.embedding_model,
|
||||
"input": texts,
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.authed(self.client.post(&url))
|
||||
.json(&body)
|
||||
let mut request = self.authed(self.client.post(&url)).json(&body);
|
||||
request = crate::otel::with_traceparent(request, span.span_context());
|
||||
let resp = request
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("POST {} failed", url))?;
|
||||
.with_context(|| format!("POST {} failed", url))
|
||||
.inspect_err(|_| {
|
||||
span.set_status(Status::error("OpenRouter embedding request failed"));
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
crate::otel::record_http_failure(
|
||||
Some(&mut span),
|
||||
status.as_u16(),
|
||||
&format!("OpenRouter embedding request failed: {status}"),
|
||||
);
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
bail!("OpenRouter embedding request failed: {} — {}", status, body);
|
||||
}
|
||||
crate::otel::set_http_status(Some(&mut span), resp.status().as_u16());
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EmbedResponse {
|
||||
@@ -646,18 +637,28 @@ impl LlmClient for OpenRouterClient {
|
||||
}
|
||||
|
||||
let parsed: EmbedResponse = resp.json().await.context("parsing embed response")?;
|
||||
span.set_status(Status::Ok);
|
||||
Ok(parsed.data.into_iter().map(|i| i.embedding).collect())
|
||||
}
|
||||
|
||||
async fn describe_image(&self, image_base64: &str) -> Result<String> {
|
||||
async fn describe_image(&self, cx: &Context, image_base64: &str) -> Result<String> {
|
||||
// Own span with operation=describe_image — the funnel into
|
||||
// chat_with_tools_impl keeps the request on a single span.
|
||||
let mut span = self.start_request_span(cx, "describe_image", &self.primary_model);
|
||||
let prompt = "Briefly describe what you see in this image in 1-2 sentences. \
|
||||
Focus on the people, location, and activity.";
|
||||
self.generate(
|
||||
prompt,
|
||||
Some("You are a scene description assistant. Be concise and factual."),
|
||||
Some(vec![image_base64.to_string()]),
|
||||
)
|
||||
.await
|
||||
let mut messages: Vec<ChatMessage> = Vec::new();
|
||||
messages.push(ChatMessage::system(
|
||||
"You are a scene description assistant. Be concise and factual.",
|
||||
));
|
||||
let mut user = ChatMessage::user(prompt);
|
||||
user.images = Some(vec![image_base64.to_string()]);
|
||||
messages.push(user);
|
||||
|
||||
let (reply, _, _) = self
|
||||
.chat_with_tools_impl(Some(&mut span), messages, Vec::new())
|
||||
.await?;
|
||||
Ok(reply.content)
|
||||
}
|
||||
|
||||
async fn list_models(&self) -> Result<Vec<ModelCapabilities>> {
|
||||
@@ -711,6 +712,124 @@ impl LlmClient for OpenRouterClient {
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenRouterClient {
|
||||
/// HTTP core of `chat_with_tools`. `span` is the `llm.request` span
|
||||
/// started by the caller (or a funnelling entry point like
|
||||
/// `describe_image`) so attributes from the response land on the right
|
||||
/// operation label.
|
||||
async fn chat_with_tools_impl(
|
||||
&self,
|
||||
mut span: Option<&mut opentelemetry::global::BoxedSpan>,
|
||||
messages: Vec<ChatMessage>,
|
||||
tools: Vec<Tool>,
|
||||
) -> Result<(ChatMessage, Option<i32>, Option<i32>)> {
|
||||
let url = format!("{}/chat/completions", self.base_url);
|
||||
let mut body = serde_json::Map::new();
|
||||
body.insert("model".into(), Value::String(self.primary_model.clone()));
|
||||
body.insert(
|
||||
"messages".into(),
|
||||
Value::Array(Self::messages_to_openai(&messages)),
|
||||
);
|
||||
body.insert("stream".into(), Value::Bool(false));
|
||||
if !tools.is_empty() {
|
||||
body.insert(
|
||||
"tools".into(),
|
||||
serde_json::to_value(&tools).context("serializing tools")?,
|
||||
);
|
||||
}
|
||||
for (k, v) in self.build_options() {
|
||||
body.insert(k.into(), v);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"OpenRouter chat_with_tools: model={} messages={} tools={}",
|
||||
self.primary_model,
|
||||
messages.len(),
|
||||
tools.len()
|
||||
);
|
||||
|
||||
let mut request = self
|
||||
.authed(self.client.post(&url))
|
||||
.json(&Value::Object(body));
|
||||
if let Some(s) = span.as_mut() {
|
||||
request = crate::otel::with_traceparent(request, s.span_context());
|
||||
}
|
||||
let resp = match request.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
if let Some(s) = span.as_mut() {
|
||||
s.set_status(Status::error("OpenRouter request failed"));
|
||||
}
|
||||
return Err(e).with_context(|| format!("POST {} failed", url));
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
crate::otel::record_http_failure(
|
||||
span.as_deref_mut(),
|
||||
status.as_u16(),
|
||||
&format!("OpenRouter chat request failed: {status}"),
|
||||
);
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
bail!("OpenRouter chat request failed: {} — {}", status, body);
|
||||
}
|
||||
crate::otel::set_http_status(span.as_deref_mut(), resp.status().as_u16());
|
||||
|
||||
let parsed: Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
if let Some(s) = span.as_mut() {
|
||||
s.set_status(Status::error("parsing chat response failed"));
|
||||
}
|
||||
return Err(e).context("parsing chat response");
|
||||
}
|
||||
};
|
||||
let choice = parsed
|
||||
.get("choices")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"response missing choices[0]: {}",
|
||||
extract_openrouter_error_detail(&parsed)
|
||||
)
|
||||
})?;
|
||||
let msg = choice.get("message").ok_or_else(|| {
|
||||
anyhow!(
|
||||
"choices[0] missing message: {}",
|
||||
extract_openrouter_error_detail(&parsed)
|
||||
)
|
||||
})?;
|
||||
let chat_msg = Self::openai_message_to_chat(msg)?;
|
||||
|
||||
let usage = parsed.get("usage");
|
||||
let prompt_tokens = usage
|
||||
.and_then(|u| u.get("prompt_tokens"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|n| n as i32);
|
||||
let completion_tokens = usage
|
||||
.and_then(|u| u.get("completion_tokens"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|n| n as i32);
|
||||
// OpenRouter reports per-request USD cost in `usage.cost`.
|
||||
if let Some(s) = span.as_mut() {
|
||||
if let Some(pt) = prompt_tokens {
|
||||
s.set_attribute(KeyValue::new("llm.tokens.input", pt as i64));
|
||||
}
|
||||
if let Some(ct) = completion_tokens {
|
||||
s.set_attribute(KeyValue::new("llm.tokens.output", ct as i64));
|
||||
}
|
||||
if let Some(cost) = usage.and_then(|u| u.get("cost")).and_then(|c| c.as_f64()) {
|
||||
s.set_attribute(KeyValue::new("llm.cost_usd", cost));
|
||||
}
|
||||
s.set_status(Status::Ok);
|
||||
}
|
||||
|
||||
Ok((chat_msg, prompt_tokens, completion_tokens))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a diagnostic fragment from an OpenRouter response body that
|
||||
/// doesn't match the expected `{choices: [...]}` shape. OpenRouter will
|
||||
/// sometimes return 200 OK with `{"error": {"message": "...", "code": ...}}`
|
||||
|
||||
+13
-3
@@ -26,7 +26,7 @@ use actix_web::{HttpRequest, HttpResponse, Responder, delete, get, post, web};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use chrono::Utc;
|
||||
use opentelemetry::KeyValue;
|
||||
use opentelemetry::trace::{Span, Status, Tracer};
|
||||
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
@@ -685,6 +685,7 @@ impl PersonaChatSession {
|
||||
entry_clone.clone(),
|
||||
user_id,
|
||||
library_id,
|
||||
&mut span,
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
@@ -721,6 +722,7 @@ impl PersonaChatSession {
|
||||
entry: Arc<TurnEntry>,
|
||||
user_id: i32,
|
||||
_library_id: i32,
|
||||
turn_span: &mut opentelemetry::global::BoxedSpan,
|
||||
) -> Result<()> {
|
||||
// Per-conversation mutex — serializes this turn against a
|
||||
// concurrent rewind or branch switch on the same transcript.
|
||||
@@ -808,6 +810,12 @@ impl PersonaChatSession {
|
||||
.resolve_backend(backend_kind, &overrides)
|
||||
.await?;
|
||||
let model_used = backend.model().to_string();
|
||||
// llm.* attributes on the turn-level span (issue #130), plus the
|
||||
// parent context for the child llm.request spans.
|
||||
turn_span.set_attribute(KeyValue::new("llm.backend", backend.kind.as_str()));
|
||||
turn_span.set_attribute(KeyValue::new("llm.model", model_used.clone()));
|
||||
let turn_cx = opentelemetry::Context::new()
|
||||
.with_remote_span_context(turn_span.span_context().clone());
|
||||
|
||||
// Build a no-photo tool catalog. `current_gate_opts_for_persona`
|
||||
// already probes each tool's backing table for presence and
|
||||
@@ -874,6 +882,7 @@ impl PersonaChatSession {
|
||||
&persona.persona_id,
|
||||
max_iterations,
|
||||
&entry,
|
||||
&turn_cx,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("persona chat loop failed: {e:?}"))?;
|
||||
@@ -942,7 +951,7 @@ impl PersonaChatSession {
|
||||
// list falls back to the persona name while the title is empty.
|
||||
if needs_title {
|
||||
let title = self
|
||||
.generate_title(&backend, &messages)
|
||||
.generate_title(&backend, &messages, &turn_cx)
|
||||
.await
|
||||
.unwrap_or_else(|| fallback_title(&messages));
|
||||
if !title.is_empty() {
|
||||
@@ -1047,6 +1056,7 @@ impl PersonaChatSession {
|
||||
&self,
|
||||
backend: &crate::ai::backend::ResolvedBackend,
|
||||
messages: &[ChatMessage],
|
||||
cx: &opentelemetry::Context,
|
||||
) -> Option<String> {
|
||||
let user_message = messages.iter().find(|m| m.role == "user")?;
|
||||
// The last assistant message with actual prose — earlier ones may be
|
||||
@@ -1057,7 +1067,7 @@ impl PersonaChatSession {
|
||||
.find(|m| m.role == "assistant" && !m.content.trim().is_empty())?;
|
||||
|
||||
let prompt = title_prompt(&user_message.content, &reply.content);
|
||||
match backend.chat().generate(&prompt, None, None).await {
|
||||
match backend.chat().generate(cx, &prompt, None, None).await {
|
||||
Ok(raw) => {
|
||||
let title = sanitize_title(&crate::ai::llm_client::strip_think_blocks(&raw));
|
||||
if title.is_empty() { None } else { Some(title) }
|
||||
|
||||
@@ -324,6 +324,7 @@ impl SmsApiClient {
|
||||
&self,
|
||||
messages: &[SmsMessage],
|
||||
ollama: &OllamaClient,
|
||||
cx: &opentelemetry::Context,
|
||||
) -> Result<String> {
|
||||
if messages.is_empty() {
|
||||
return Ok(String::from("No messages on this day"));
|
||||
@@ -356,6 +357,7 @@ Summary:"#,
|
||||
|
||||
ollama
|
||||
.generate(
|
||||
cx,
|
||||
&prompt,
|
||||
// Some("You are a summarizer for the purposes of jogging my memory and highlighting events and situations."),
|
||||
Some("You are the keeper of memories, ingest the context and give me a casual summary of the moment."),
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Pre-cutover audit utility for the filename date class (ticket #117).
|
||||
//!
|
||||
//! One-shot, operator-run: lists every file matching a filename-class
|
||||
//! pattern across the configured libraries — the file's current stored
|
||||
//! date + source and the date / source / offset-source the re-based
|
||||
//! resolver would assign it. Strictly read-only: it changes nothing.
|
||||
//!
|
||||
//! This is the human spot-check that gates the production backfill
|
||||
//! drain: review the report, fix any mis-tagged pattern or
|
||||
//! misclassified file, then start the drain.
|
||||
//!
|
||||
//! Usage: `cargo run --bin filename_audit` (reads DATABASE_URL and
|
||||
//! EXCLUDED_DIRS from the environment, like the server).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use diesel::Connection;
|
||||
use diesel::sqlite::SqliteConnection;
|
||||
use image_api::filename_audit::{format_report, run_audit};
|
||||
use image_api::libraries::load_all;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
env_logger::init();
|
||||
dotenv::dotenv().ok();
|
||||
|
||||
let database_url = dotenv::var("DATABASE_URL")
|
||||
.map_err(|_| anyhow::anyhow!("DATABASE_URL is not set (check .env)"))?;
|
||||
let base_path = dotenv::var("BASE_PATH").ok();
|
||||
|
||||
let mut conn =
|
||||
SqliteConnection::establish(&database_url).map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
|
||||
// Migrations are already run by the server; the audit only SELECTs.
|
||||
// A fresh database without the date-class columns would fail the
|
||||
// select below with a clear SQL error — that is the expected signal.
|
||||
|
||||
let global_excludes: Vec<String> = dotenv::var("EXCLUDED_DIRS")
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
let libraries = load_all(&mut conn);
|
||||
if libraries.is_empty() {
|
||||
anyhow::bail!(
|
||||
"no rows in the `libraries` table — is DATABASE_URL pointing at the live database?"
|
||||
);
|
||||
}
|
||||
|
||||
// BASE_PATH is informational for the operator (the libraries table
|
||||
// carries the authoritative roots); warn when it differs from every
|
||||
// known root so a stale env var doesn't mislead the review.
|
||||
if let Some(base) = &base_path
|
||||
&& let base = PathBuf::from(base)
|
||||
&& !libraries
|
||||
.iter()
|
||||
.any(|l| PathBuf::from(&l.root_path) == base)
|
||||
{
|
||||
eprintln!(
|
||||
"note: BASE_PATH ({}) does not match any library root; auditing the library table",
|
||||
base.display()
|
||||
);
|
||||
}
|
||||
|
||||
println!("Filename date class audit (read-only)");
|
||||
println!("======================================");
|
||||
println!("Libraries: {}", libraries.len());
|
||||
println!();
|
||||
|
||||
let (rows, skipped) = run_audit(&mut conn, libraries, &global_excludes);
|
||||
print!("{}", format_report(&rows, &skipped));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -185,6 +185,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new())));
|
||||
let face_dao: Arc<Mutex<Box<dyn FaceDao>>> =
|
||||
Arc::new(Mutex::new(Box::new(SqliteFaceDao::new())));
|
||||
let user_ai_prefs_dao: Arc<Mutex<Box<dyn image_api::database::UserAiPrefsDao>>> = Arc::new(
|
||||
Mutex::new(Box::new(image_api::database::SqliteUserAiPrefsDao::new())),
|
||||
);
|
||||
let persona_dao: Arc<Mutex<Box<dyn image_api::database::PersonaDao>>> = Arc::new(Mutex::new(
|
||||
Box::new(image_api::database::SqlitePersonaDao::new()),
|
||||
));
|
||||
@@ -208,6 +211,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
face_dao,
|
||||
knowledge_dao,
|
||||
persona_dao,
|
||||
user_ai_prefs_dao,
|
||||
all_libs.clone(),
|
||||
);
|
||||
|
||||
|
||||
@@ -220,7 +220,9 @@ async fn main() -> Result<()> {
|
||||
|
||||
println!("Generating summary...");
|
||||
|
||||
let summary = ollama.generate(&prompt, Some(system_prompt)).await?;
|
||||
let summary = ollama
|
||||
.generate(&opentelemetry::Context::new(), &prompt, Some(system_prompt))
|
||||
.await?;
|
||||
|
||||
println!("\n📝 GENERATED SUMMARY:");
|
||||
println!("─────────────────────────────────────────");
|
||||
@@ -236,7 +238,9 @@ async fn main() -> Result<()> {
|
||||
);
|
||||
|
||||
println!("\nGenerating embedding...");
|
||||
let embedding = ollama.generate_embedding(&stripped).await?;
|
||||
let embedding = ollama
|
||||
.generate_embedding(&opentelemetry::Context::new(), &stripped)
|
||||
.await?;
|
||||
println!("✓ Embedding generated ({} dimensions)", embedding.len());
|
||||
|
||||
println!("Saving to database...");
|
||||
|
||||
+44
-1
@@ -170,7 +170,16 @@ pub fn resolve_camera_offset(
|
||||
}
|
||||
let minutes = match server_zone {
|
||||
Some(zone) => offset_minutes_for_zone(zone, wall),
|
||||
None => Local::now().offset().fix().local_minus_utc() / 60,
|
||||
// Zone name unresolvable on this host: assume the process-local
|
||||
// zone, but at the PHOTO's wall (a DST host's current-season
|
||||
// offset would skew historical captures by an hour). Ambiguous
|
||||
// walls take the earliest (pre-transition) reading; truly
|
||||
// nonexistent local times fall back to the current offset.
|
||||
None => match Local.from_local_datetime(&wall) {
|
||||
LocalResult::Single(dt) => dt.offset().fix().local_minus_utc() / 60,
|
||||
LocalResult::Ambiguous(dt, _) => dt.offset().fix().local_minus_utc() / 60,
|
||||
LocalResult::None => Local::now().offset().fix().local_minus_utc() / 60,
|
||||
},
|
||||
};
|
||||
OffsetResolution {
|
||||
minutes,
|
||||
@@ -338,6 +347,40 @@ mod tests {
|
||||
assert_eq!(res.source, TzSource::AssumedServer);
|
||||
}
|
||||
|
||||
/// The offset the process-local zone assigns to a wall clock —
|
||||
/// independent of the current season.
|
||||
fn local_offset_at(wall: NaiveDateTime) -> i32 {
|
||||
Local
|
||||
.from_local_datetime(&wall)
|
||||
.earliest()
|
||||
.map(|dt| dt.offset().fix().local_minus_utc() / 60)
|
||||
.unwrap_or_else(|| Local::now().offset().fix().local_minus_utc() / 60)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assumed_server_fallback_is_dst_aware_at_the_wall_when_zone_unresolvable() {
|
||||
// `server_zone() = None` (e.g. a container whose /etc/localtime is a
|
||||
// copy, not a symlink): the fallback assumes the process-local zone
|
||||
// AT THE PHOTO'S WALL, not the current season's offset. On a DST
|
||||
// host this test catches the old behavior whenever the host is in
|
||||
// either season (one of the two walls disagrees with `now`);
|
||||
// on a fixed-offset host both assertions hold trivially.
|
||||
let evidence = OffsetEvidence::default();
|
||||
let winter = resolve_camera_offset(wall_january(), &evidence, None, &StubLookup(None));
|
||||
let summer = resolve_camera_offset(wall_july(), &evidence, None, &StubLookup(None));
|
||||
assert_eq!(
|
||||
winter.minutes,
|
||||
local_offset_at(wall_january()),
|
||||
"winter wall must carry the winter offset"
|
||||
);
|
||||
assert_eq!(
|
||||
summer.minutes,
|
||||
local_offset_at(wall_july()),
|
||||
"summer wall must carry the summer offset"
|
||||
);
|
||||
assert_eq!(winter.source, TzSource::AssumedServer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gps_miss_falls_through_to_server_zone() {
|
||||
let evidence = OffsetEvidence {
|
||||
|
||||
@@ -428,7 +428,15 @@ pub struct GpsPhotoSummary {
|
||||
pub path: String,
|
||||
pub lat: f64,
|
||||
pub lon: f64,
|
||||
/// Legacy mixed-convention date (frozen for existing rows; ADR-0001).
|
||||
pub date_taken: Option<i64>,
|
||||
/// True instant (epoch seconds), when the row has been converted.
|
||||
pub date_utc: Option<i64>,
|
||||
/// Camera offset in minutes from UTC at capture, when known.
|
||||
pub camera_offset_minutes: Option<i32>,
|
||||
/// Which evidence pinned the instant (`offset_tag`, `gps`,
|
||||
/// `assumed_server`, `utc_direct`, `manual`).
|
||||
pub date_taken_tz_source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
||||
+179
-124
@@ -9,21 +9,6 @@ use crate::database::models::{
|
||||
};
|
||||
use crate::otel::trace_db_call;
|
||||
|
||||
/// Decoded shape for `get_memories_in_window`'s raw `sql_query`. Diesel's
|
||||
/// query DSL doesn't expose strftime, so the memories filter is hand-
|
||||
/// written SQL — but the returned columns are simple enough that a small
|
||||
/// `QueryableByName` struct suffices, kept private to this module.
|
||||
#[derive(diesel::QueryableByName)]
|
||||
#[allow(dead_code)] // fields read via Diesel's QueryableByName derive
|
||||
struct MemoriesWindowRow {
|
||||
#[diesel(sql_type = diesel::sql_types::Text)]
|
||||
rel_path: String,
|
||||
#[diesel(sql_type = diesel::sql_types::BigInt)]
|
||||
date_taken: i64,
|
||||
#[diesel(sql_type = diesel::sql_types::BigInt)]
|
||||
last_modified: i64,
|
||||
}
|
||||
|
||||
/// Wire shape for a single member of a duplicate group, returned by
|
||||
/// `list_duplicates_*` and `lookup_duplicate_row`. Carries everything
|
||||
/// the Apollo modal needs to render a member tile and its meta line —
|
||||
@@ -441,7 +426,18 @@ pub trait ExifDao: Sync + Send {
|
||||
context: &opentelemetry::Context,
|
||||
base_path: &str,
|
||||
recursive: bool,
|
||||
) -> Result<Vec<(String, f64, f64, Option<i64>)>, DbError>;
|
||||
) -> Result<
|
||||
Vec<(
|
||||
String,
|
||||
f64,
|
||||
f64,
|
||||
Option<i64>,
|
||||
Option<i64>,
|
||||
Option<i32>,
|
||||
Option<String>,
|
||||
)>,
|
||||
DbError,
|
||||
>;
|
||||
|
||||
/// Return rows that still lack a `content_hash`, oldest first. Used by
|
||||
/// the `backfill_hashes` binary to batch through the historical
|
||||
@@ -652,34 +648,34 @@ pub trait ExifDao: Sync + Send {
|
||||
rel_path: &str,
|
||||
) -> Result<ImageExif, DbError>;
|
||||
|
||||
/// Single-query backend for `/memories`. Returns
|
||||
/// `(rel_path, date_taken, last_modified)` for rows in `library_id`
|
||||
/// whose `date_taken` falls within `[now - years_back y, now]` and
|
||||
/// whose calendar position matches the request's span:
|
||||
/// - `"day"` — same month + day-of-month (any year)
|
||||
/// - `"week"` — same week-of-year (SQLite `%W`, Monday-anchored —
|
||||
/// close to but not exactly ISO week 8601; the boundary cases
|
||||
/// at year-start/end can shift by ±1 vs the prior request-time
|
||||
/// `iso_week()` filter)
|
||||
/// - `"month"` — same month (any year)
|
||||
/// Coarse pre-filter for the `/memories` span query (ADR-0001).
|
||||
///
|
||||
/// `tz_offset_minutes` is applied to both sides of the strftime
|
||||
/// comparison so the calendar match is in the user's local time.
|
||||
/// Backed by the `(library_id, date_taken)` index.
|
||||
/// The span feature matches the same calendar position (month-day /
|
||||
/// week-of-year / month) across every past year, so this query only
|
||||
/// bounds the rows by epoch windows; the exact span-day match happens
|
||||
/// in the caller (`memories::day_in_span`), because it needs the
|
||||
/// owner's display zone.
|
||||
///
|
||||
/// This is the single-SQL replacement for the EXIF-loop +
|
||||
/// WalkDir-fallback that powered `/memories` previously; it's
|
||||
/// correct only because the canonical-date waterfall at ingest
|
||||
/// (`crate::date_resolver`) populates `date_taken` for every row
|
||||
/// it can resolve.
|
||||
/// Eligibility:
|
||||
/// - `date_taken` must be set and sit inside the unconverted band
|
||||
/// `[unconv_lo, unconv_hi]` (a ±15 h margin around the years-back
|
||||
/// window that covers every per-source legacy convention, and which
|
||||
/// also caps how far into the future a row may sit);
|
||||
/// - converted rows (`date_utc IS NOT NULL`) additionally need
|
||||
/// `date_utc` inside `[conv_lo, conv_hi]` — the true-instant
|
||||
/// window; unconverted rows pass on the band alone.
|
||||
///
|
||||
/// Returns full rows so the caller can read the date triple
|
||||
/// (`date_utc` / `camera_offset_minutes` / `date_taken_tz_source`).
|
||||
fn get_memories_in_window(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id: i32,
|
||||
span_token: &str,
|
||||
years_back: i32,
|
||||
tz_offset_minutes: i32,
|
||||
) -> Result<Vec<(String, i64, i64)>, DbError>;
|
||||
conv_lo: i64,
|
||||
conv_hi: i64,
|
||||
unconv_lo: i64,
|
||||
unconv_hi: i64,
|
||||
) -> Result<Vec<ImageExif>, DbError>;
|
||||
|
||||
/// Return image rows that have a `content_hash` but no `phash_64`,
|
||||
/// oldest first. Used by the `backfill_perceptual_hash` binary.
|
||||
@@ -1293,12 +1289,24 @@ impl ExifDao for SqliteExifDao {
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn get_all_with_gps(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
base_path: &str,
|
||||
recursive: bool,
|
||||
) -> Result<Vec<(String, f64, f64, Option<i64>)>, DbError> {
|
||||
) -> Result<
|
||||
Vec<(
|
||||
String,
|
||||
f64,
|
||||
f64,
|
||||
Option<i64>,
|
||||
Option<i64>,
|
||||
Option<i32>,
|
||||
Option<String>,
|
||||
)>,
|
||||
DbError,
|
||||
> {
|
||||
trace_db_call(context, "query", "get_all_with_gps", |span| {
|
||||
use opentelemetry::KeyValue;
|
||||
use opentelemetry::trace::Span;
|
||||
@@ -1334,10 +1342,19 @@ impl ExifDao for SqliteExifDao {
|
||||
.load::<ImageExif>(connection)
|
||||
.map_err(|e| anyhow::anyhow!("GPS query error: {}", e))?;
|
||||
|
||||
// Convert to tuple format (path, lat, lon, date_taken)
|
||||
// Convert to tuple format (path, lat, lon, legacy date_taken,
|
||||
// date_utc, camera_offset_minutes, date_taken_tz_source).
|
||||
// Filter out any rows where GPS is still None (shouldn't happen due to filter)
|
||||
// Cast f32 GPS values to f64 for API compatibility
|
||||
let filtered: Vec<(String, f64, f64, Option<i64>)> = results
|
||||
let filtered: Vec<(
|
||||
String,
|
||||
f64,
|
||||
f64,
|
||||
Option<i64>,
|
||||
Option<i64>,
|
||||
Option<i32>,
|
||||
Option<String>,
|
||||
)> = results
|
||||
.into_iter()
|
||||
.filter_map(|exif| {
|
||||
if let (Some(lat_val), Some(lon_val)) = (exif.gps_latitude, exif.gps_longitude)
|
||||
@@ -1347,6 +1364,9 @@ impl ExifDao for SqliteExifDao {
|
||||
lat_val as f64,
|
||||
lon_val as f64,
|
||||
exif.date_taken,
|
||||
exif.date_utc,
|
||||
exif.camera_offset_minutes,
|
||||
exif.date_taken_tz_source,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
@@ -1937,51 +1957,27 @@ impl ExifDao for SqliteExifDao {
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id: i32,
|
||||
span_token: &str,
|
||||
years_back: i32,
|
||||
tz_offset_minutes: i32,
|
||||
) -> Result<Vec<(String, i64, i64)>, DbError> {
|
||||
conv_lo: i64,
|
||||
conv_hi: i64,
|
||||
unconv_lo: i64,
|
||||
unconv_hi: i64,
|
||||
) -> Result<Vec<ImageExif>, DbError> {
|
||||
trace_db_call(context, "query", "get_memories_in_window", |_span| {
|
||||
// strftime pattern is span-dependent; the rest of the WHERE
|
||||
// clause is shared. Only `%m-%d`, `%W`, `%m` are accepted —
|
||||
// anything else is a programmer error.
|
||||
let pattern = match span_token {
|
||||
"day" => "%m-%d",
|
||||
"week" => "%W",
|
||||
"month" => "%m",
|
||||
_ => return Err(anyhow::anyhow!("invalid span token: {}", span_token)),
|
||||
};
|
||||
|
||||
// SQLite's date modifiers want a string like `'-480 minutes'`
|
||||
// (signed) or `'-15 years'`. Use the `+` flag so positive
|
||||
// offsets render as `+480 minutes`.
|
||||
let tz_modifier = format!("{:+} minutes", tz_offset_minutes);
|
||||
let years_modifier = format!("-{} years", years_back);
|
||||
|
||||
let sql = format!(
|
||||
"SELECT rel_path, date_taken, last_modified \
|
||||
FROM image_exif \
|
||||
WHERE library_id = ?1 \
|
||||
AND date_taken IS NOT NULL \
|
||||
AND date_taken <= unixepoch('now') \
|
||||
AND date_taken >= unixepoch('now', ?2) \
|
||||
AND strftime('{p}', date_taken, 'unixepoch', ?3) \
|
||||
= strftime('{p}', 'now', ?3)",
|
||||
p = pattern,
|
||||
);
|
||||
use crate::database::schema::image_exif::dsl as d;
|
||||
|
||||
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
|
||||
|
||||
diesel::sql_query(sql)
|
||||
.bind::<diesel::sql_types::Integer, _>(library_id)
|
||||
.bind::<diesel::sql_types::Text, _>(years_modifier)
|
||||
.bind::<diesel::sql_types::Text, _>(tz_modifier)
|
||||
.load::<MemoriesWindowRow>(connection.deref_mut())
|
||||
.map(|rows| {
|
||||
rows.into_iter()
|
||||
.map(|r| (r.rel_path, r.date_taken, r.last_modified))
|
||||
.collect()
|
||||
})
|
||||
d::image_exif
|
||||
.filter(d::library_id.eq(library_id))
|
||||
.filter(d::date_taken.is_not_null())
|
||||
.filter(d::date_taken.ge(unconv_lo))
|
||||
.filter(d::date_taken.le(unconv_hi))
|
||||
.filter(
|
||||
d::date_utc
|
||||
.is_null()
|
||||
.or(d::date_utc.ge(conv_lo).and(d::date_utc.le(conv_hi))),
|
||||
)
|
||||
.load::<ImageExif>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
@@ -2966,25 +2962,21 @@ mod exif_dao_tests {
|
||||
assert_eq!(row.size_bytes, Some(1024));
|
||||
}
|
||||
|
||||
/// Wide intervals that admit everything in the years-back window —
|
||||
/// span-day matching happens in the caller, so tests that don't care
|
||||
/// about the span pass these.
|
||||
/// Mirrors the production caller (`memories::memories_span_windows`):
|
||||
/// the converted window spans the full years-back horizon and the
|
||||
/// unconverted band pads it by ±15 h — so far-future legacy values
|
||||
/// are dropped at the DAO, not by the band.
|
||||
fn wide_windows(now_ts: i64, years_back: i64) -> (i64, i64, i64, i64) {
|
||||
let lo = now_ts - years_back * 366 * 86_400;
|
||||
(lo, now_ts, lo - 15 * 3600, now_ts + 15 * 3600)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_memories_in_window_day_matches_only_same_md_in_year_window() {
|
||||
fn get_memories_in_window_filters_future_no_date_and_years_back() {
|
||||
let mut dao = setup_two_libraries();
|
||||
|
||||
// Anchor on a known date so the test is timezone-stable: insert
|
||||
// rows whose date_taken IS the same wall-clock time as `now()`
|
||||
// would have been some N years ago, and verify the day-span
|
||||
// filter returns them. We can't bind 'now' from Rust, so instead
|
||||
// we insert rows for the *current* day (offset by 365 days * N
|
||||
// years) and rely on SQLite computing the same `%m-%d` for both
|
||||
// sides of the equality. Using the unix-now-minus-365*N seconds
|
||||
// approximation is good enough — leap years drift by ~one day
|
||||
// every four years, but the test only checks day-of-year match
|
||||
// for rows inserted "today minus N years (no leap correction)".
|
||||
// To dodge the leap-year drift entirely, we use rows whose
|
||||
// calendar date is read back from SQLite and we just check
|
||||
// membership.
|
||||
|
||||
// 1y, 5y, 10y, 21y back from 'now':
|
||||
let now_ts = chrono::Utc::now().timestamp();
|
||||
let year_secs: i64 = 365 * 86_400;
|
||||
insert_row_with_source(
|
||||
@@ -2994,13 +2986,6 @@ mod exif_dao_tests {
|
||||
Some(now_ts - year_secs),
|
||||
Some("exif"),
|
||||
);
|
||||
insert_row_with_source(
|
||||
&mut dao,
|
||||
1,
|
||||
"y5.jpg",
|
||||
Some(now_ts - 5 * year_secs),
|
||||
Some("exif"),
|
||||
);
|
||||
insert_row_with_source(
|
||||
&mut dao,
|
||||
1,
|
||||
@@ -3027,20 +3012,15 @@ mod exif_dao_tests {
|
||||
// No date — never returned regardless of source.
|
||||
insert_row_with_source(&mut dao, 1, "nodate.jpg", None, None);
|
||||
|
||||
// Month span returns rows from the same calendar month over the
|
||||
// window — y1, y5, y10 should all qualify (same month any year),
|
||||
// y21 trims (out of years_back), future trims (> now), nodate
|
||||
// never qualifies. Day-of-month leap drift means even with 365-
|
||||
// day approximation a row may shift by one in either direction;
|
||||
// month is the safer assertion under that approximation.
|
||||
let (clo, chi, ulo, uhi) = wide_windows(now_ts, 20);
|
||||
let rows = dao
|
||||
.get_memories_in_window(&ctx(), 1, "month", 20, 0)
|
||||
.get_memories_in_window(&ctx(), 1, clo, chi, ulo, uhi)
|
||||
.unwrap();
|
||||
let paths: std::collections::HashSet<String> =
|
||||
rows.into_iter().map(|(p, _, _)| p).collect();
|
||||
rows.into_iter().map(|r| r.file_path).collect();
|
||||
assert!(
|
||||
paths.contains("y1.jpg") && paths.contains("y5.jpg") && paths.contains("y10.jpg"),
|
||||
"month span should include all in-window rows: {:?}",
|
||||
paths.contains("y1.jpg") && paths.contains("y10.jpg"),
|
||||
"in-window rows should be returned: {:?}",
|
||||
paths
|
||||
);
|
||||
assert!(
|
||||
@@ -3067,23 +3047,98 @@ mod exif_dao_tests {
|
||||
Some(now_ts - year),
|
||||
Some("exif"),
|
||||
);
|
||||
|
||||
let (clo, chi, ulo, uhi) = wide_windows(now_ts, 20);
|
||||
let lib1 = dao
|
||||
.get_memories_in_window(&ctx(), 1, "month", 20, 0)
|
||||
.get_memories_in_window(&ctx(), 1, clo, chi, ulo, uhi)
|
||||
.unwrap();
|
||||
let lib2 = dao
|
||||
.get_memories_in_window(&ctx(), 2, "month", 20, 0)
|
||||
.get_memories_in_window(&ctx(), 2, clo, chi, ulo, uhi)
|
||||
.unwrap();
|
||||
assert_eq!(lib1.len(), 1);
|
||||
assert_eq!(lib1[0].0, "main/x.jpg");
|
||||
assert_eq!(lib1[0].file_path, "main/x.jpg");
|
||||
assert_eq!(lib2.len(), 1);
|
||||
assert_eq!(lib2[0].0, "archive/x.jpg");
|
||||
assert_eq!(lib2[0].file_path, "archive/x.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_memories_in_window_rejects_unknown_span_token() {
|
||||
fn get_memories_in_window_branches_converted_on_date_utc() {
|
||||
let mut dao = setup_two_libraries();
|
||||
let err = dao.get_memories_in_window(&ctx(), 1, "decade", 20, 0);
|
||||
assert!(err.is_err());
|
||||
let now_ts = chrono::Utc::now().timestamp();
|
||||
let year = 365 * 86_400i64;
|
||||
|
||||
// A converted row whose `date_utc` sits INSIDE the conv interval
|
||||
// but whose legacy `date_taken` sits OUTSIDE the unconv interval:
|
||||
// it must still be returned (proof the converted branch won).
|
||||
insert_row_with_source(&mut dao, 1, "conv.jpg", Some(now_ts - year), Some("exif"));
|
||||
dao.backfill_date_taken(
|
||||
&ctx(),
|
||||
1,
|
||||
"conv.jpg",
|
||||
now_ts - year,
|
||||
"exif",
|
||||
Some(now_ts - year - 9000), // shifted true instant
|
||||
None,
|
||||
Some("assumed_server"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// An unconverted row: judged on legacy `date_taken` only.
|
||||
insert_row_with_source(
|
||||
&mut dao,
|
||||
1,
|
||||
"unconv.jpg",
|
||||
Some(now_ts - year),
|
||||
Some("fs_time"),
|
||||
);
|
||||
|
||||
// A converted row OUTSIDE the conv interval: excluded even though
|
||||
// its legacy value is inside the unconv band.
|
||||
insert_row_with_source(
|
||||
&mut dao,
|
||||
1,
|
||||
"conv-out.jpg",
|
||||
Some(now_ts - year),
|
||||
Some("exif"),
|
||||
);
|
||||
dao.backfill_date_taken(
|
||||
&ctx(),
|
||||
1,
|
||||
"conv-out.jpg",
|
||||
now_ts - year,
|
||||
"exif",
|
||||
Some(now_ts - 30 * year),
|
||||
None,
|
||||
Some("assumed_server"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// conv interval: a tight band around (now - year - 9000) that
|
||||
// excludes (now - 30*year) and excludes the unconv row's legacy
|
||||
// (now - year) is irrelevant — it's unconverted. The unconv band
|
||||
// is wide for unconv.jpg.
|
||||
let clo = now_ts - year - 86_400;
|
||||
let chi = now_ts - year;
|
||||
let ulo = now_ts - year - 86_400;
|
||||
let uhi = now_ts;
|
||||
let rows = dao
|
||||
.get_memories_in_window(&ctx(), 1, clo, chi, ulo, uhi)
|
||||
.unwrap();
|
||||
let paths: std::collections::HashSet<String> =
|
||||
rows.into_iter().map(|r| r.file_path).collect();
|
||||
assert!(
|
||||
paths.contains("conv.jpg"),
|
||||
"converted row in conv band: {:?}",
|
||||
paths
|
||||
);
|
||||
assert!(
|
||||
paths.contains("unconv.jpg"),
|
||||
"unconverted row in legacy band: {:?}",
|
||||
paths
|
||||
);
|
||||
assert!(
|
||||
!paths.contains("conv-out.jpg"),
|
||||
"converted row outside conv band must be excluded: {:?}",
|
||||
paths
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -579,6 +579,19 @@ pub struct UserAiPrefs {
|
||||
/// default (`camera_tz::DEFAULT_DISPLAY_TZ`). The legacy
|
||||
/// `tz_offset_minutes` column stays frozen for rollback.
|
||||
pub display_tz: Option<String>,
|
||||
/// Last/explicit insight + chat model choice (local id or OpenRouter id).
|
||||
/// NULL = current per-request / env default behavior.
|
||||
pub default_model: Option<String>,
|
||||
/// `"local"` | `"hybrid"` — insight/chat backend. NULL = per-request /
|
||||
/// hard default ("local").
|
||||
pub default_backend: Option<String>,
|
||||
/// Active persona for persona chat. NULL = hard default ("default").
|
||||
pub default_persona_id: Option<String>,
|
||||
/// Reel span: `"day"` | `"week"` | `"month"`. NULL = per-request /
|
||||
/// hard default (day).
|
||||
pub reel_span: Option<String>,
|
||||
/// Reel segment cap. NULL = per-request / hard default.
|
||||
pub reel_max_segments: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Insertable, Debug, Clone, serde::Deserialize, serde::Serialize)]
|
||||
@@ -589,4 +602,9 @@ pub struct UpsertUserAiPrefs {
|
||||
pub library: Option<String>,
|
||||
pub updated_at: i64,
|
||||
pub display_tz: Option<String>,
|
||||
pub default_model: Option<String>,
|
||||
pub default_backend: Option<String>,
|
||||
pub default_persona_id: Option<String>,
|
||||
pub reel_span: Option<String>,
|
||||
pub reel_max_segments: Option<i32>,
|
||||
}
|
||||
@@ -293,6 +293,11 @@ diesel::table! {
|
||||
library -> Nullable<Text>,
|
||||
updated_at -> BigInt,
|
||||
display_tz -> Nullable<Text>,
|
||||
default_model -> Nullable<Text>,
|
||||
default_backend -> Nullable<Text>,
|
||||
default_persona_id -> Nullable<Text>,
|
||||
reel_span -> Nullable<Text>,
|
||||
reel_max_segments -> Nullable<Integer>,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,11 @@ impl UserAiPrefsDao for SqliteUserAiPrefsDao {
|
||||
dsl::tz_offset_minutes.eq(&prefs.tz_offset_minutes),
|
||||
dsl::library.eq(&prefs.library),
|
||||
dsl::display_tz.eq(&prefs.display_tz),
|
||||
dsl::default_model.eq(&prefs.default_model),
|
||||
dsl::default_backend.eq(&prefs.default_backend),
|
||||
dsl::default_persona_id.eq(&prefs.default_persona_id),
|
||||
dsl::reel_span.eq(&prefs.reel_span),
|
||||
dsl::reel_max_segments.eq(&prefs.reel_max_segments),
|
||||
dsl::updated_at.eq(&prefs.updated_at),
|
||||
))
|
||||
.execute(connection.deref_mut())
|
||||
@@ -147,6 +152,11 @@ mod tests {
|
||||
library: Some("1".to_string()),
|
||||
updated_at: now,
|
||||
display_tz: Some("America/New_York".to_string()),
|
||||
default_model: Some("llama3.2:3b".to_string()),
|
||||
default_backend: Some("hybrid".to_string()),
|
||||
default_persona_id: Some("cabin".to_string()),
|
||||
reel_span: Some("week".to_string()),
|
||||
reel_max_segments: Some(24),
|
||||
};
|
||||
dao.upsert_prefs(&ctx(), &prefs).unwrap();
|
||||
|
||||
@@ -156,6 +166,11 @@ mod tests {
|
||||
assert_eq!(row.tz_offset_minutes, Some(-480));
|
||||
assert_eq!(row.library, Some("1".to_string()));
|
||||
assert_eq!(row.display_tz, Some("America/New_York".to_string()));
|
||||
assert_eq!(row.default_model, Some("llama3.2:3b".to_string()));
|
||||
assert_eq!(row.default_backend, Some("hybrid".to_string()));
|
||||
assert_eq!(row.default_persona_id, Some("cabin".to_string()));
|
||||
assert_eq!(row.reel_span, Some("week".to_string()));
|
||||
assert_eq!(row.reel_max_segments, Some(24));
|
||||
assert_eq!(row.updated_at, now);
|
||||
}
|
||||
|
||||
@@ -171,6 +186,11 @@ mod tests {
|
||||
library: Some("1".to_string()),
|
||||
updated_at: now1,
|
||||
display_tz: Some("America/New_York".to_string()),
|
||||
default_model: Some("llama3.2:3b".to_string()),
|
||||
default_backend: Some("local".to_string()),
|
||||
default_persona_id: None,
|
||||
reel_span: Some("week".to_string()),
|
||||
reel_max_segments: Some(24),
|
||||
};
|
||||
dao.upsert_prefs(&ctx(), &prefs1).unwrap();
|
||||
|
||||
@@ -180,6 +200,11 @@ mod tests {
|
||||
library: None,
|
||||
updated_at: now2,
|
||||
display_tz: None,
|
||||
default_model: None,
|
||||
default_backend: None,
|
||||
default_persona_id: None,
|
||||
reel_span: None,
|
||||
reel_max_segments: None,
|
||||
};
|
||||
dao.upsert_prefs(&ctx(), &prefs2).unwrap();
|
||||
|
||||
@@ -188,6 +213,11 @@ mod tests {
|
||||
assert_eq!(row.tz_offset_minutes, Some(-300));
|
||||
assert!(row.library.is_none());
|
||||
assert!(row.display_tz.is_none());
|
||||
// Owner-default columns are overwritten to NULL like the rest.
|
||||
assert!(row.default_model.is_none());
|
||||
assert!(row.default_backend.is_none());
|
||||
assert!(row.reel_span.is_none());
|
||||
assert!(row.reel_max_segments.is_none());
|
||||
assert_eq!(row.updated_at, now2);
|
||||
}
|
||||
|
||||
@@ -202,6 +232,11 @@ mod tests {
|
||||
library: None,
|
||||
updated_at: now,
|
||||
display_tz: Some("Europe/Berlin".to_string()),
|
||||
default_model: None,
|
||||
default_backend: None,
|
||||
default_persona_id: Some("default".to_string()),
|
||||
reel_span: Some("month".to_string()),
|
||||
reel_max_segments: None,
|
||||
};
|
||||
dao.upsert_prefs(&ctx(), &prefs).unwrap();
|
||||
|
||||
@@ -210,5 +245,9 @@ mod tests {
|
||||
assert!(row.voice.is_none());
|
||||
assert!(row.library.is_none());
|
||||
assert_eq!(row.display_tz, Some("Europe/Berlin".to_string()));
|
||||
assert!(row.default_model.is_none());
|
||||
assert_eq!(row.default_persona_id, Some("default".to_string()));
|
||||
assert_eq!(row.reel_span, Some("month".to_string()));
|
||||
assert!(row.reel_max_segments.is_none());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
//! Pre-cutover audit utility for the filename date class (ticket #117).
|
||||
//!
|
||||
//! A one-shot, operator-run report listing every file that matches a
|
||||
//! filename-class pattern: the file's current stored date + source and
|
||||
//! the date / source / offset-source the re-based resolver would assign
|
||||
//! it. It reads the live database and walks the libraries (honoring the
|
||||
//! library exclusions and the online gate — offline libraries are
|
||||
//! reported as skipped, never processed). It changes nothing: strictly
|
||||
//! read-only, no writes to any table.
|
||||
//!
|
||||
//! Purpose: the filename class is a documented inversion of EXIF-wins
|
||||
//! and depends on the operator's memory of "what these filenames mean".
|
||||
//! This report is the human spot-check that gates the production
|
||||
//! backfill drain — the operator reviews it, fixes any mis-tagged
|
||||
//! pattern or misclassified file, and only then starts the drain.
|
||||
//!
|
||||
//! The proposed values mirror the drain's inputs: the resolver runs
|
||||
//! with empty priors, exactly as the per-tick drain calls
|
||||
//! `resolve_dates_batch` today. In-class files never touch exiftool, so
|
||||
//! the audit spawns no subprocesses at all.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use diesel::prelude::*;
|
||||
use diesel::sqlite::SqliteConnection;
|
||||
|
||||
use crate::database::schema::image_exif;
|
||||
use crate::date_resolver::ResolvedDate;
|
||||
use crate::libraries::{Library, LibraryHealth, probe_online};
|
||||
use crate::memories::{PathExcluder, match_filename_date};
|
||||
|
||||
/// The stored date contract for one `image_exif` row: the legacy pair
|
||||
/// plus the new-contract audit value (NULL until the drain converts it).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct StoredDate {
|
||||
pub date_taken: Option<i64>,
|
||||
pub date_taken_source: Option<String>,
|
||||
pub date_taken_tz_source: Option<String>,
|
||||
}
|
||||
|
||||
/// One report row: a filename-class file, what the database has for it
|
||||
/// now, and what the re-based resolver would assign.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuditRow {
|
||||
pub library_id: i32,
|
||||
pub library_name: String,
|
||||
/// Root-relative path, slash-separated.
|
||||
pub rel_path: String,
|
||||
/// Which filename-class pattern matched (`screenshot`, `dash`, …).
|
||||
pub pattern: &'static str,
|
||||
/// The row's stored contract; `None` when the file is not indexed
|
||||
/// yet (no `image_exif` row).
|
||||
pub current: Option<StoredDate>,
|
||||
/// What the re-based resolver would assign (legacy value, source,
|
||||
/// true instant, offset, offset-source).
|
||||
pub proposed: ResolvedDate,
|
||||
/// True when a stored row exists and its legacy `date_taken`
|
||||
/// disagrees with the proposed legacy value — the rows the operator
|
||||
/// needs to spot-check hardest.
|
||||
pub changed: bool,
|
||||
}
|
||||
|
||||
/// A library the audit did not process, with the reason. Disabled
|
||||
/// (operator kill switch) and offline (probe failed) are reported,
|
||||
/// never partially processed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SkippedLibrary {
|
||||
pub library_id: i32,
|
||||
pub name: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// One `image_exif` row's date contract, as loaded for the audit.
|
||||
#[derive(diesel::Queryable)]
|
||||
struct StoredDateRow {
|
||||
rel_path: String,
|
||||
date_taken: Option<i64>,
|
||||
date_taken_source: Option<String>,
|
||||
date_taken_tz_source: Option<String>,
|
||||
}
|
||||
|
||||
/// Load `rel_path -> stored date fields` for one library. Read-only
|
||||
/// (SELECTs only; `&mut` is diesel's connection contract, not a write).
|
||||
pub fn stored_dates_for_library(
|
||||
conn: &mut SqliteConnection,
|
||||
library_id: i32,
|
||||
) -> Result<HashMap<String, StoredDate>, diesel::result::Error> {
|
||||
let rows: Vec<StoredDateRow> = image_exif::table
|
||||
.filter(image_exif::library_id.eq(library_id))
|
||||
.select((
|
||||
image_exif::rel_path,
|
||||
image_exif::date_taken,
|
||||
image_exif::date_taken_source,
|
||||
image_exif::date_taken_tz_source,
|
||||
))
|
||||
.load(conn)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|StoredDateRow {
|
||||
rel_path,
|
||||
date_taken,
|
||||
date_taken_source,
|
||||
date_taken_tz_source,
|
||||
}| {
|
||||
(
|
||||
rel_path,
|
||||
StoredDate {
|
||||
date_taken,
|
||||
date_taken_source,
|
||||
date_taken_tz_source,
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Audit one online library: walk its root (honoring the effective
|
||||
/// excludes), match the filename class, and compute the proposed
|
||||
/// resolution for every in-class file in a single resolver pass. Rows
|
||||
/// are stable-sorted by `rel_path`. `stored` comes from
|
||||
/// [`stored_dates_for_library`].
|
||||
pub fn audit_library(
|
||||
library: &Library,
|
||||
global_excludes: &[String],
|
||||
stored: &HashMap<String, StoredDate>,
|
||||
) -> Vec<AuditRow> {
|
||||
let root = PathBuf::from(&library.root_path);
|
||||
let excluder = PathExcluder::new(&root, &library.effective_excluded_dirs(global_excludes));
|
||||
|
||||
let mut matched: Vec<(String, PathBuf, &'static str)> = Vec::new();
|
||||
// Depth 0 (the root itself) is always allowed so a pathological
|
||||
// exclude matching the root can't silently yield an empty report —
|
||||
// same guard as file_scan::walk_library_files.
|
||||
for entry in walkdir::WalkDir::new(&root)
|
||||
.into_iter()
|
||||
.filter_entry(|e| e.depth() == 0 || !excluder.is_excluded(e.path()))
|
||||
.flatten()
|
||||
{
|
||||
if !entry.file_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(rel) = entry
|
||||
.path()
|
||||
.strip_prefix(&root)
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(fm) = match_filename_date(entry.file_name().to_str().unwrap_or("")) else {
|
||||
continue;
|
||||
};
|
||||
matched.push((rel, entry.path().to_path_buf(), fm.pattern));
|
||||
}
|
||||
matched.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
// One resolver pass for the whole library. In-class files resolve
|
||||
// from the name alone, so this spawns no exiftool subprocess.
|
||||
let paths: Vec<PathBuf> = matched.iter().map(|(_, p, _)| p.clone()).collect();
|
||||
let resolved = crate::date_resolver::resolve_dates_batch(&paths, &HashMap::new());
|
||||
|
||||
matched
|
||||
.into_iter()
|
||||
.map(|(rel_path, abs, pattern)| {
|
||||
// An in-class name always resolves (the class never fails to
|
||||
// date a file it previously dated); `None` would be a bug in
|
||||
// the resolver, not a missing file.
|
||||
let proposed = resolved
|
||||
.get(&abs)
|
||||
.copied()
|
||||
.expect("in-class filename must resolve without exiftool");
|
||||
let current = stored.get(&rel_path).cloned();
|
||||
let changed = current
|
||||
.as_ref()
|
||||
.map(|c| c.date_taken != Some(proposed.timestamp))
|
||||
.unwrap_or(false);
|
||||
AuditRow {
|
||||
library_id: library.id,
|
||||
library_name: library.name.clone(),
|
||||
rel_path,
|
||||
pattern,
|
||||
current,
|
||||
proposed,
|
||||
changed,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Run the audit over every configured library: disabled libraries are
|
||||
/// reported as skipped; online libraries are walked; probe-failed
|
||||
/// libraries are reported as skipped (never partially processed). The
|
||||
/// result is stable-sorted by library id, then `rel_path`, so two runs
|
||||
/// diff cleanly.
|
||||
pub fn run_audit(
|
||||
conn: &mut SqliteConnection,
|
||||
libraries: Vec<Library>,
|
||||
global_excludes: &[String],
|
||||
) -> (Vec<AuditRow>, Vec<SkippedLibrary>) {
|
||||
let mut rows: Vec<AuditRow> = Vec::new();
|
||||
let mut skipped: Vec<SkippedLibrary> = Vec::new();
|
||||
|
||||
for lib in libraries {
|
||||
if !lib.enabled {
|
||||
skipped.push(SkippedLibrary {
|
||||
library_id: lib.id,
|
||||
name: lib.name.clone(),
|
||||
reason: "disabled (enabled=false)".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Same online gate as the watcher: probe with prior knowledge
|
||||
// (had_data) from the image_exif row count.
|
||||
let had_data: i64 = image_exif::table
|
||||
.filter(image_exif::library_id.eq(lib.id))
|
||||
.count()
|
||||
.get_result(conn)
|
||||
.unwrap_or(0);
|
||||
match probe_online(&lib, had_data > 0) {
|
||||
LibraryHealth::Online => {
|
||||
let stored = stored_dates_for_library(conn, lib.id).unwrap_or_default();
|
||||
rows.extend(audit_library(&lib, global_excludes, &stored));
|
||||
}
|
||||
LibraryHealth::Stale { reason, .. } => {
|
||||
skipped.push(SkippedLibrary {
|
||||
library_id: lib.id,
|
||||
name: lib.name.clone(),
|
||||
reason: format!("offline ({reason})"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows.sort_by(|a, b| {
|
||||
(a.library_id, a.rel_path.as_str()).cmp(&(b.library_id, b.rel_path.as_str()))
|
||||
});
|
||||
(rows, skipped)
|
||||
}
|
||||
|
||||
fn format_epoch_utc(ts: Option<i64>) -> String {
|
||||
match ts.and_then(|t| chrono::DateTime::<chrono::Utc>::from_timestamp(t, 0)) {
|
||||
Some(dt) => dt.format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
None => "-".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the report. One entry per in-class file — three lines each:
|
||||
/// header, `current:`, `proposed:` — stable-sorted, with a `*` on rows
|
||||
/// whose stored legacy date would change; then the skipped libraries
|
||||
/// and a summary. Deterministic: two runs diff cleanly.
|
||||
pub fn format_report(rows: &[AuditRow], skipped: &[SkippedLibrary]) -> String {
|
||||
let mut out = String::new();
|
||||
for row in rows {
|
||||
let current = match &row.current {
|
||||
Some(c) => format!(
|
||||
"{} [{}]",
|
||||
format_epoch_utc(c.date_taken),
|
||||
c.date_taken_source.as_deref().unwrap_or("-")
|
||||
),
|
||||
None => "unindexed".to_string(),
|
||||
};
|
||||
let proposed_tz = row
|
||||
.proposed
|
||||
.tz_source
|
||||
.map(|t| t.as_str().to_string())
|
||||
.unwrap_or_else(|| "-".into());
|
||||
// The numeric offset (minutes ahead of UTC) is the value the
|
||||
// operator actually spot-checks: a wall pattern resolved with
|
||||
// empty evidence carries the server zone's offset for that date.
|
||||
let proposed_offset = row
|
||||
.proposed
|
||||
.camera_offset_minutes
|
||||
.map(|m| format!("{m:+}m"))
|
||||
.unwrap_or_else(|| "-".into());
|
||||
out.push_str(&format!(
|
||||
"{}\t{:<19}\t{:<20}\tlib={} {}\n current: {}\n proposed: {} [{}] tz_source={} offset={}\n",
|
||||
if row.changed { "*" } else { " " },
|
||||
row.pattern,
|
||||
row.rel_path,
|
||||
row.library_id,
|
||||
row.library_name,
|
||||
current,
|
||||
format_epoch_utc(Some(row.proposed.timestamp)),
|
||||
row.proposed.source.as_str(),
|
||||
proposed_tz,
|
||||
proposed_offset,
|
||||
));
|
||||
}
|
||||
if !skipped.is_empty() {
|
||||
out.push_str("\nskipped libraries:\n");
|
||||
for s in skipped {
|
||||
out.push_str(&format!(
|
||||
" lib={} {} — {}\n",
|
||||
s.library_id, s.name, s.reason
|
||||
));
|
||||
}
|
||||
}
|
||||
out.push_str(&format!(
|
||||
"\n{} file(s) in the filename class, {} would change, {} library(ies) skipped\n",
|
||||
rows.len(),
|
||||
rows.iter().filter(|r| r.changed).count(),
|
||||
skipped.len()
|
||||
));
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use diesel::Connection;
|
||||
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!();
|
||||
|
||||
fn migrated_conn() -> SqliteConnection {
|
||||
let mut conn = SqliteConnection::establish(":memory:").expect("in-memory sqlite");
|
||||
conn.run_pending_migrations(DB_MIGRATIONS)
|
||||
.expect("migrations");
|
||||
conn
|
||||
}
|
||||
|
||||
fn seed_row(
|
||||
conn: &mut SqliteConnection,
|
||||
library_id: i32,
|
||||
rel_path: &str,
|
||||
date_taken: Option<i64>,
|
||||
source: Option<&str>,
|
||||
) {
|
||||
diesel::insert_into(image_exif::table)
|
||||
.values((
|
||||
image_exif::library_id.eq(library_id),
|
||||
image_exif::rel_path.eq(rel_path),
|
||||
image_exif::date_taken.eq(date_taken),
|
||||
image_exif::date_taken_source.eq(source),
|
||||
image_exif::created_time.eq(1_700_000_000),
|
||||
image_exif::last_modified.eq(1_700_000_000),
|
||||
))
|
||||
.execute(conn)
|
||||
.expect("seed row");
|
||||
}
|
||||
|
||||
fn library(id: i32, name: &str, root: &std::path::Path, enabled: bool) -> Library {
|
||||
Library {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
root_path: root.to_string_lossy().to_string(),
|
||||
enabled,
|
||||
excluded_dirs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixture library seeded with mixed conventions:
|
||||
/// - `Screenshot_2014-06-01-20-44-50.png` — in-class wall, stored row
|
||||
/// says exif/2021 (the inversion the operator must spot-check).
|
||||
/// - `att_1422489664680106.jpeg` — in-class unix timestamp, stored
|
||||
/// row already agrees.
|
||||
/// - `2015-01-09_02-15-15.jpg` — in-class wall, not indexed yet.
|
||||
/// - `plain_photo.jpg` — out of class, must not appear.
|
||||
/// - `@eaDir/2016-02-02_03-03-03.jpg` — excluded dir, must not appear.
|
||||
fn fixture_library(root: &std::path::Path) {
|
||||
for path in [
|
||||
"Screenshot_2014-06-01-20-44-50.png",
|
||||
"att_1422489664680106.jpeg",
|
||||
"2015-01-09_02-15-15.jpg",
|
||||
"plain_photo.jpg",
|
||||
"@eaDir/2016-02-02_03-03-03.jpg",
|
||||
] {
|
||||
let full = root.join(path);
|
||||
if let Some(parent) = full.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(&full, b"fixture").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_shows_inversion_timestamp_and_exclusions() {
|
||||
let tmp = tempdir().unwrap();
|
||||
fixture_library(tmp.path());
|
||||
let mut conn = migrated_conn();
|
||||
|
||||
// 2021-01-15 12:00:00 UTC — a stale EXIF date the filename
|
||||
// outranks. att_1422489664680106 -> first 10 digits =
|
||||
// 1422489664 = 2015-01-29 07:21:04 UTC.
|
||||
let stale_exif: i64 = 1_610_742_400;
|
||||
seed_row(
|
||||
&mut conn,
|
||||
1,
|
||||
"Screenshot_2014-06-01-20-44-50.png",
|
||||
Some(stale_exif),
|
||||
Some("exif"),
|
||||
);
|
||||
seed_row(
|
||||
&mut conn,
|
||||
1,
|
||||
"att_1422489664680106.jpeg",
|
||||
Some(1422489664),
|
||||
Some("filename"),
|
||||
);
|
||||
|
||||
let lib = library(1, "photos", tmp.path(), true);
|
||||
let stored = stored_dates_for_library(&mut conn, 1).unwrap();
|
||||
// Global excludes as the EXCLUDED_DIRS env var would provide in
|
||||
// production (junk dirs like @eaDir never enter the class walk).
|
||||
let rows = audit_library(&lib, &["@eaDir".to_string()], &stored);
|
||||
|
||||
// Exactly the three in-class files; out-of-class and excluded
|
||||
// paths are absent.
|
||||
let rels: Vec<&str> = rows.iter().map(|r| r.rel_path.as_str()).collect();
|
||||
assert_eq!(
|
||||
rels,
|
||||
vec![
|
||||
"2015-01-09_02-15-15.jpg",
|
||||
"Screenshot_2014-06-01-20-44-50.png",
|
||||
"att_1422489664680106.jpeg"
|
||||
]
|
||||
);
|
||||
|
||||
// The inversion: stored exif/2021, proposed filename/2014 wall
|
||||
// (wall-as-UTC legacy), tz_source assumed_server (empty priors,
|
||||
// mirroring the drain).
|
||||
let inv = rows
|
||||
.iter()
|
||||
.find(|r| r.rel_path == "Screenshot_2014-06-01-20-44-50.png")
|
||||
.unwrap();
|
||||
assert_eq!(inv.pattern, "screenshot");
|
||||
assert_eq!(inv.current.as_ref().unwrap().date_taken, Some(stale_exif));
|
||||
assert_eq!(
|
||||
inv.current.as_ref().unwrap().date_taken_source.as_deref(),
|
||||
Some("exif")
|
||||
);
|
||||
assert_eq!(inv.proposed.source.as_str(), "filename");
|
||||
assert_eq!(
|
||||
inv.proposed.timestamp,
|
||||
1401655490 // 2014-06-01 20:44:50 as UTC (wall-as-UTC legacy)
|
||||
);
|
||||
assert_eq!(
|
||||
inv.proposed.tz_source.map(|t| t.as_str()),
|
||||
Some("assumed_server")
|
||||
);
|
||||
// Server zone America/New_York is EDT (UTC-4) in June.
|
||||
assert_eq!(inv.proposed.camera_offset_minutes, Some(-240));
|
||||
assert!(inv.changed, "stored exif date must flag as changed");
|
||||
|
||||
// The unix timestamp: utc_direct, no offset, and the stored row
|
||||
// already agrees -> not flagged.
|
||||
let ts = rows
|
||||
.iter()
|
||||
.find(|r| r.rel_path == "att_1422489664680106.jpeg")
|
||||
.unwrap();
|
||||
assert_eq!(ts.pattern, "unix_timestamp");
|
||||
assert_eq!(ts.proposed.source.as_str(), "filename");
|
||||
assert_eq!(ts.proposed.timestamp, 1422489664);
|
||||
assert_eq!(ts.proposed.date_utc, Some(1422489664));
|
||||
assert_eq!(ts.proposed.camera_offset_minutes, None);
|
||||
assert_eq!(
|
||||
ts.proposed.tz_source.map(|t| t.as_str()),
|
||||
Some("utc_direct")
|
||||
);
|
||||
assert!(!ts.changed);
|
||||
|
||||
// Unindexed in-class file: current is None, not flagged.
|
||||
let unidx = rows
|
||||
.iter()
|
||||
.find(|r| r.rel_path == "2015-01-09_02-15-15.jpg")
|
||||
.unwrap();
|
||||
assert_eq!(unidx.pattern, "dash");
|
||||
assert!(unidx.current.is_none());
|
||||
assert!(!unidx.changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_audit_skips_disabled_and_offline_libraries() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let online_root = tmp.path().join("online");
|
||||
let missing_root = tmp.path().join("missing");
|
||||
fs::create_dir_all(&online_root).unwrap();
|
||||
fixture_library(&online_root);
|
||||
// missing_root is never created -> probe fails.
|
||||
|
||||
let mut conn = migrated_conn();
|
||||
// Give the offline library a row so had_data=true (the strict
|
||||
// probe: empty dir with rows is suspicious).
|
||||
seed_row(&mut conn, 2, "whatever.jpg", Some(1), Some("exif"));
|
||||
|
||||
let libs = vec![
|
||||
library(2, "offline", &missing_root, true),
|
||||
library(1, "online", &online_root, true),
|
||||
library(3, "disabled", &online_root, false),
|
||||
];
|
||||
let (rows, skipped) = run_audit(&mut conn, libs, &[]);
|
||||
|
||||
let skipped_ids: Vec<i32> = skipped.iter().map(|s| s.library_id).collect();
|
||||
assert_eq!(skipped_ids, vec![2, 3]);
|
||||
assert!(skipped[0].reason.starts_with("offline"));
|
||||
assert_eq!(skipped[1].reason, "disabled (enabled=false)");
|
||||
|
||||
// Only the online library was processed.
|
||||
let libs_seen: Vec<i32> = rows.iter().map(|r| r.library_id).collect();
|
||||
assert!(libs_seen.iter().all(|id| *id == 1));
|
||||
assert!(!rows.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_audit_sorts_by_library_then_path() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let a = tmp.path().join("a");
|
||||
let b = tmp.path().join("b");
|
||||
fs::create_dir_all(&a).unwrap();
|
||||
fs::create_dir_all(&b).unwrap();
|
||||
fs::write(a.join("2015-02-02_02-02-02.jpg"), b"").unwrap();
|
||||
fs::write(a.join("Screenshot_2014-06-01-20-44-50.png"), b"").unwrap();
|
||||
fs::write(b.join("1401638400.jpeg"), b"").unwrap();
|
||||
// Intentionally pass libraries out of order.
|
||||
let libs = vec![library(2, "b", &b, true), library(1, "a", &a, true)];
|
||||
let mut conn = migrated_conn();
|
||||
let (rows, skipped) = run_audit(&mut conn, libs, &[]);
|
||||
assert!(skipped.is_empty());
|
||||
let got: Vec<(i32, String)> = rows
|
||||
.iter()
|
||||
.map(|r| (r.library_id, r.rel_path.clone()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
(1, "2015-02-02_02-02-02.jpg".to_string()),
|
||||
(1, "Screenshot_2014-06-01-20-44-50.png".to_string()),
|
||||
(2, "1401638400.jpeg".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_report_is_deterministic_and_marks_changed() {
|
||||
let tmp = tempdir().unwrap();
|
||||
fixture_library(tmp.path());
|
||||
let mut conn = migrated_conn();
|
||||
seed_row(
|
||||
&mut conn,
|
||||
1,
|
||||
"Screenshot_2014-06-01-20-44-50.png",
|
||||
Some(1_610_742_400),
|
||||
Some("exif"),
|
||||
);
|
||||
let lib = library(1, "photos", tmp.path(), true);
|
||||
let (rows, skipped) = run_audit(&mut conn, vec![lib], &["@eaDir".to_string()]);
|
||||
let first = format_report(&rows, &skipped);
|
||||
let second = format_report(&rows, &skipped);
|
||||
assert_eq!(first, second);
|
||||
assert!(first.contains("*\t"), "changed row must be flagged");
|
||||
assert!(first.contains("screenshot"));
|
||||
assert!(first.contains("utc_direct"));
|
||||
// Numeric offset is printed: the wall row carries the server-zone
|
||||
// offset for that date, timestamp rows have none.
|
||||
assert!(first.contains("offset=-240m"));
|
||||
assert!(first.contains("offset=-\n"));
|
||||
// The stored source renders without debug quotes (P1 from
|
||||
// review): `2021-... [exif]`, never `["exif"]`.
|
||||
assert!(first.contains("[exif]"));
|
||||
assert!(!first.contains("\"exif\""));
|
||||
assert!(first.contains("3 file(s) in the filename class, 1 would change"));
|
||||
// Excluded path must not appear anywhere in the report.
|
||||
assert!(!first.contains("2016-02-02_03-03-03.jpg"));
|
||||
}
|
||||
}
|
||||
+36
-11
@@ -1142,12 +1142,25 @@ pub async fn get_gps_summary(
|
||||
Ok(gps_data) => {
|
||||
let mut photos: Vec<GpsPhotoSummary> = gps_data
|
||||
.into_iter()
|
||||
.map(|(path, lat, lon, date_taken)| GpsPhotoSummary {
|
||||
path,
|
||||
lat,
|
||||
lon,
|
||||
date_taken,
|
||||
})
|
||||
.map(
|
||||
|(
|
||||
path,
|
||||
lat,
|
||||
lon,
|
||||
date_taken,
|
||||
date_utc,
|
||||
camera_offset_minutes,
|
||||
date_taken_tz_source,
|
||||
)| GpsPhotoSummary {
|
||||
path,
|
||||
lat,
|
||||
lon,
|
||||
date_taken,
|
||||
date_utc,
|
||||
camera_offset_minutes,
|
||||
date_taken_tz_source,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
// Sort by date_taken based on request, defaulting to ascending (oldest to newest)
|
||||
@@ -1695,7 +1708,18 @@ mod tests {
|
||||
_context: &opentelemetry::Context,
|
||||
_base_path: &str,
|
||||
_recursive: bool,
|
||||
) -> Result<Vec<(String, f64, f64, Option<i64>)>, DbError> {
|
||||
) -> Result<
|
||||
Vec<(
|
||||
String,
|
||||
f64,
|
||||
f64,
|
||||
Option<i64>,
|
||||
Option<i64>,
|
||||
Option<i32>,
|
||||
Option<String>,
|
||||
)>,
|
||||
DbError,
|
||||
> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
@@ -1817,10 +1841,11 @@ mod tests {
|
||||
&mut self,
|
||||
_context: &opentelemetry::Context,
|
||||
_library_id: i32,
|
||||
_span_token: &str,
|
||||
_years_back: i32,
|
||||
_tz_offset_minutes: i32,
|
||||
) -> Result<Vec<(String, i64, i64)>, DbError> {
|
||||
_conv_lo: i64,
|
||||
_conv_hi: i64,
|
||||
_unconv_lo: i64,
|
||||
_unconv_hi: i64,
|
||||
) -> Result<Vec<crate::database::models::ImageExif>, DbError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -859,7 +859,8 @@ async fn synthesize_merge<D: KnowledgeDao + 'static>(
|
||||
|
||||
let ollama = app_state.ollama.clone();
|
||||
let model_used = ollama.primary_model.clone();
|
||||
let proposed = match ollama.generate(&prompt, Some(system)).await {
|
||||
let cx = opentelemetry::Context::current();
|
||||
let proposed = match ollama.generate(&cx, &prompt, Some(system)).await {
|
||||
Ok(out) => {
|
||||
// Strip the framing models reach for even with explicit
|
||||
// "no preamble" guidance: leading "Merged description:"
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod content_hash;
|
||||
pub mod data;
|
||||
pub mod database;
|
||||
pub mod date_resolver;
|
||||
pub mod date_view;
|
||||
pub mod duplicates;
|
||||
pub mod error;
|
||||
pub mod exif;
|
||||
@@ -21,6 +22,7 @@ pub mod face_watch;
|
||||
pub mod faces;
|
||||
pub mod file_scan;
|
||||
pub mod file_types;
|
||||
pub mod filename_audit;
|
||||
pub mod files;
|
||||
pub mod geo;
|
||||
pub mod libraries;
|
||||
@@ -30,6 +32,7 @@ pub mod otel;
|
||||
pub mod parsers;
|
||||
pub mod perceptual_hash;
|
||||
pub mod personas;
|
||||
pub mod prefs;
|
||||
pub mod service;
|
||||
pub mod state;
|
||||
pub mod tags;
|
||||
|
||||
@@ -41,6 +41,7 @@ mod content_hash;
|
||||
mod data;
|
||||
mod database;
|
||||
mod date_resolver;
|
||||
mod date_view;
|
||||
mod duplicates;
|
||||
mod error;
|
||||
mod exif;
|
||||
@@ -67,6 +68,7 @@ mod knowledge;
|
||||
mod memories;
|
||||
mod otel;
|
||||
mod personas;
|
||||
mod prefs;
|
||||
mod reels;
|
||||
mod service;
|
||||
#[cfg(test)]
|
||||
@@ -373,6 +375,12 @@ fn main() -> std::io::Result<()> {
|
||||
.service(reels::reel_video_handler)
|
||||
.service(reels::precomputed_reel_handler)
|
||||
.service(reels::precomputed_video_handler)
|
||||
.service(
|
||||
web::resource("/prefs")
|
||||
.route(web::get().to(prefs::get_prefs))
|
||||
.route(web::put().to(prefs::put_prefs)),
|
||||
)
|
||||
.service(web::resource("/timezones").route(web::get().to(prefs::list_timezones)))
|
||||
.service(ai::generate_insight_handler)
|
||||
.service(ai::generate_agentic_insight_handler)
|
||||
.service(ai::generation_status_handler)
|
||||
|
||||
+565
-61
@@ -1,12 +1,13 @@
|
||||
use actix_web::web::Data;
|
||||
use actix_web::{HttpRequest, HttpResponse, Responder, get, web};
|
||||
use chrono::{DateTime, FixedOffset, Local, NaiveDate, NaiveDateTime, Utc};
|
||||
use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, TimeZone, Utc};
|
||||
use log::{debug, trace, warn};
|
||||
use opentelemetry::KeyValue;
|
||||
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::data::Claims;
|
||||
@@ -107,10 +108,38 @@ pub enum MemoriesSpan {
|
||||
Month,
|
||||
}
|
||||
|
||||
/// Wire/storage string for the span (`"day"` | `"week"` | `"month"`) — the
|
||||
/// same lowercase names as the serde rename, so a stored pref round-trips
|
||||
/// through `MemoriesRequest.span` unchanged.
|
||||
impl std::fmt::Display for MemoriesSpan {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
MemoriesSpan::Day => "day",
|
||||
MemoriesSpan::Week => "week",
|
||||
MemoriesSpan::Month => "month",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for MemoriesSpan {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"day" => Ok(MemoriesSpan::Day),
|
||||
"week" => Ok(MemoriesSpan::Week),
|
||||
"month" => Ok(MemoriesSpan::Month),
|
||||
other => Err(format!("invalid span {other:?} (expected day|week|month)")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MemoriesRequest {
|
||||
pub span: Option<MemoriesSpan>,
|
||||
/// Client timezone offset in minutes from UTC (e.g., -480 for PST, 60 for CET)
|
||||
/// Client timezone offset in minutes from UTC (e.g., -480 for PST, 60 for
|
||||
/// CET). Accepted on the wire for old clients but no longer drives
|
||||
/// grouping (ADR-0001) — grouping uses the owner's display zone.
|
||||
#[allow(dead_code)]
|
||||
pub timezone_offset_minutes: Option<i32>,
|
||||
/// Optional library filter. Accepts a library id (e.g. "1") or name
|
||||
/// (e.g. "main"). When omitted, results span all libraries.
|
||||
@@ -120,11 +149,21 @@ pub struct MemoriesRequest {
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct MemoryItem {
|
||||
pub path: String,
|
||||
/// True instant (unix seconds) the memory names (ADR-0001) — the
|
||||
/// grouping anchor. For unconverted rows this is *derived* from the
|
||||
/// legacy value under its per-source convention, so it is the real
|
||||
/// moment even while the `backfill_utc_dates` drain is still running.
|
||||
pub created: Option<i64>,
|
||||
pub modified: Option<i64>,
|
||||
/// Id of the library this memory belongs to. Allows clients to show a
|
||||
/// per-item source badge in union mode.
|
||||
pub library_id: i32,
|
||||
/// New-contract triple (ADR-0001). `date_utc` is `None` until the
|
||||
/// `backfill_utc_dates` drain has converted the row; clients should
|
||||
/// prefer it over `created` when present.
|
||||
pub date_utc: Option<i64>,
|
||||
pub camera_offset_minutes: Option<i32>,
|
||||
pub date_taken_tz_source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -132,6 +171,50 @@ pub struct MemoriesResponse {
|
||||
pub items: Vec<MemoryItem>,
|
||||
}
|
||||
|
||||
/// Epoch windows for the memories SQL pre-filter (ADR-0001).
|
||||
///
|
||||
/// The span feature matches the same calendar position (month-day /
|
||||
/// week-of-year / month) across every past year, so the pre-filter must
|
||||
/// cover the FULL years-back window — not just the current span window —
|
||||
/// or past-year rows are dropped before [`day_in_span`] gets to match
|
||||
/// them. Converted rows are tested on `date_utc` (the true instant);
|
||||
/// unconverted rows on legacy `date_taken`, which sits up to ~15 h off
|
||||
/// the instant under the per-source conventions (wall↔instant confusion
|
||||
/// ≤ 12 h, zone range ≤ 2 h, DST margin 1 h) — hence the ±15 h band.
|
||||
/// By construction the band subsumes the converted window, so the DAO's
|
||||
/// single outer bound on `date_taken` never drops a converted row.
|
||||
pub fn memories_span_windows(now_ts: i64, years_back: i64) -> (i64, i64, i64, i64) {
|
||||
let years_lo = now_ts - years_back * 365 * 86_400;
|
||||
(years_lo, now_ts, years_lo - 15 * 3600, now_ts + 15 * 3600)
|
||||
}
|
||||
|
||||
/// True if `display_day` falls in the span (day/week/month) containing
|
||||
/// `today` — both already in the owner's display zone. Mirrors the legacy
|
||||
/// SQLite `strftime('%m-%d' | '%W' | '%m')` equality: weeks are
|
||||
/// Monday-based and the days before the year's first Monday are week 00.
|
||||
pub fn day_in_span(display_day: NaiveDate, today: NaiveDate, span: MemoriesSpan) -> bool {
|
||||
match span {
|
||||
MemoriesSpan::Day => {
|
||||
display_day.month() == today.month() && display_day.day() == today.day()
|
||||
}
|
||||
MemoriesSpan::Week => sqlite_w_week(display_day) == sqlite_w_week(today),
|
||||
MemoriesSpan::Month => display_day.month() == today.month(),
|
||||
}
|
||||
}
|
||||
|
||||
/// SQLite's `%W`: Monday-based week-of-year, `0` for the days before the
|
||||
/// year's first Monday, `1` starting on the first Monday.
|
||||
fn sqlite_w_week(d: NaiveDate) -> i32 {
|
||||
let jan1 = NaiveDate::from_ymd_opt(d.year(), 1, 1).expect("Jan 1 exists");
|
||||
let days_in_week0 = (7 - jan1.weekday().num_days_from_monday() as i64) % 7;
|
||||
let days = (d - jan1).num_days();
|
||||
if days < days_in_week0 {
|
||||
0
|
||||
} else {
|
||||
((days - days_in_week0) / 7 + 1) as i32
|
||||
}
|
||||
}
|
||||
|
||||
/// A date carried by a filename, with its convention (ticket #116).
|
||||
///
|
||||
/// The two families of pattern make fundamentally different claims:
|
||||
@@ -146,7 +229,7 @@ pub struct MemoriesResponse {
|
||||
/// class**: the filename is the original date of record and outranks
|
||||
/// EXIF / container dates (ADR-0001). Files whose name matches nothing
|
||||
/// are out of the class and keep the global EXIF-wins waterfall.
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum FilenameDate {
|
||||
/// Wall clock the device displayed (screenshot / dash / compact).
|
||||
Wall(NaiveDateTime),
|
||||
@@ -174,12 +257,26 @@ impl FilenameDate {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
|
||||
/// A filename that matched one of the filename-class patterns (#116):
|
||||
/// which pattern fired and the date it carries.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FilenameMatch {
|
||||
/// Stable pattern name for audit / debug surfaces: `screenshot`,
|
||||
/// `screenshot_compact`, `dash`, `dash_compact`, `compact`,
|
||||
/// `unix_timestamp`.
|
||||
pub pattern: &'static str,
|
||||
pub date: FilenameDate,
|
||||
}
|
||||
|
||||
/// Match a filename against the filename-class patterns, reporting which
|
||||
/// pattern fired. This is the canonical matcher; `extract_date_from_filename`
|
||||
/// is the date-only projection of it.
|
||||
pub fn match_filename_date(filename: &str) -> Option<FilenameMatch> {
|
||||
// The wall-clock patterns (screenshot / dash / compact) carry the
|
||||
// device's own clock; the unix-timestamp pattern carries a true
|
||||
// instant. `legacy_datetime()` reproduces the pre-migration
|
||||
// interpretation of each (naive-as-UTC / epoch) for the legacy
|
||||
// column and its consumers.
|
||||
// instant. `FilenameDate::legacy_datetime()` reproduces the
|
||||
// pre-migration interpretation of each (naive-as-UTC / epoch) for the
|
||||
// legacy column and its consumers.
|
||||
let build_wall_from_ymd_capture = |captures: ®ex::Captures| -> Option<NaiveDateTime> {
|
||||
let year = captures.get(1)?.as_str().parse::<i32>().ok()?;
|
||||
let month = captures.get(2)?.as_str().parse::<u32>().ok()?;
|
||||
@@ -197,7 +294,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
|
||||
.captures(filename)
|
||||
.and_then(|c| build_wall_from_ymd_capture(&c))
|
||||
{
|
||||
return Some(FilenameDate::Wall(naive));
|
||||
return Some(FilenameMatch {
|
||||
pattern: "screenshot",
|
||||
date: FilenameDate::Wall(naive),
|
||||
});
|
||||
}
|
||||
|
||||
// Screenshot format: Screenshot_20140601[_-]204450.png
|
||||
@@ -206,7 +306,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
|
||||
.captures(filename)
|
||||
.and_then(|c| build_wall_from_ymd_capture(&c))
|
||||
{
|
||||
return Some(FilenameDate::Wall(naive));
|
||||
return Some(FilenameMatch {
|
||||
pattern: "screenshot_compact",
|
||||
date: FilenameDate::Wall(naive),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Dash format: 2015-01-09_02-15-15.jpg
|
||||
@@ -215,7 +318,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
|
||||
.captures(filename)
|
||||
.and_then(|c| build_wall_from_ymd_capture(&c))
|
||||
{
|
||||
return Some(FilenameDate::Wall(naive));
|
||||
return Some(FilenameMatch {
|
||||
pattern: "dash",
|
||||
date: FilenameDate::Wall(naive),
|
||||
});
|
||||
}
|
||||
|
||||
// Dash with compact time format: 2015-01-09-021515.jpg
|
||||
@@ -224,7 +330,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
|
||||
.captures(filename)
|
||||
.and_then(|c| build_wall_from_ymd_capture(&c))
|
||||
{
|
||||
return Some(FilenameDate::Wall(naive));
|
||||
return Some(FilenameMatch {
|
||||
pattern: "dash_compact",
|
||||
date: FilenameDate::Wall(naive),
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Compact format: 20140927101712.jpg
|
||||
@@ -233,7 +342,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
|
||||
.captures(filename)
|
||||
.and_then(|c| build_wall_from_ymd_capture(&c))
|
||||
{
|
||||
return Some(FilenameDate::Wall(naive));
|
||||
return Some(FilenameMatch {
|
||||
pattern: "compact",
|
||||
date: FilenameDate::Wall(naive),
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Timestamp format: 1401638400.jpeg, att_1422489664680106.jpeg, att_142248967186928.jpeg
|
||||
@@ -281,7 +393,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
|
||||
.and_then(DateTime::<Utc>::from_timestamp_millis)
|
||||
.and_then(plausible_filename_date)
|
||||
{
|
||||
return Some(FilenameDate::Utc(date_time));
|
||||
return Some(FilenameMatch {
|
||||
pattern: "unix_timestamp",
|
||||
date: FilenameDate::Utc(date_time),
|
||||
});
|
||||
}
|
||||
|
||||
// For 14-16 digits, treat first 10 digits as seconds to avoid far future dates
|
||||
@@ -293,7 +408,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
|
||||
.and_then(|ts| DateTime::<Utc>::from_timestamp(ts, 0))
|
||||
.and_then(plausible_filename_date)
|
||||
{
|
||||
return Some(FilenameDate::Utc(date_time));
|
||||
return Some(FilenameMatch {
|
||||
pattern: "unix_timestamp",
|
||||
date: FilenameDate::Utc(date_time),
|
||||
});
|
||||
}
|
||||
|
||||
// Exactly 10 digits - seconds since epoch
|
||||
@@ -311,7 +429,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
|
||||
if is_snapchat && date_time.timestamp() < SNAPCHAT_LAUNCH_TS {
|
||||
return None;
|
||||
}
|
||||
return Some(FilenameDate::Utc(date_time));
|
||||
return Some(FilenameMatch {
|
||||
pattern: "unix_timestamp",
|
||||
date: FilenameDate::Utc(date_time),
|
||||
});
|
||||
}
|
||||
|
||||
// 11-12 digits: try as milliseconds (might be partial millisecond timestamp)
|
||||
@@ -322,13 +443,23 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
|
||||
.and_then(DateTime::<Utc>::from_timestamp_millis)
|
||||
.and_then(plausible_filename_date)
|
||||
{
|
||||
return Some(FilenameDate::Utc(date_time));
|
||||
return Some(FilenameMatch {
|
||||
pattern: "unix_timestamp",
|
||||
date: FilenameDate::Utc(date_time),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Date-only projection of [`match_filename_date`]: the class match
|
||||
/// without the pattern name. Resolver and consumer call sites use this;
|
||||
/// the audit utility uses the full match.
|
||||
pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
|
||||
match_filename_date(filename).map(|m| m.date)
|
||||
}
|
||||
|
||||
/// Sanity gate for filename-derived timestamps. Real photo capture dates
|
||||
/// live in a narrow window; values outside it are almost always sequential
|
||||
/// scan IDs (`000227580005.jpg` → 1970) or arbitrary numeric suffixes
|
||||
@@ -350,17 +481,6 @@ fn plausible_filename_date(dt: DateTime<Utc>) -> Option<DateTime<Utc>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a `date_taken` Unix-seconds value to a `NaiveDate` in the
|
||||
/// client's local time. Falls back to server-local when the client didn't
|
||||
/// send a tz hint.
|
||||
fn date_in_client_tz(timestamp: i64, client_timezone: Option<FixedOffset>) -> Option<NaiveDate> {
|
||||
let dt = DateTime::from_timestamp(timestamp, 0)?;
|
||||
Some(match client_timezone {
|
||||
Some(tz) => dt.with_timezone(&tz).date_naive(),
|
||||
None => dt.with_timezone(&Local).date_naive(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Default lookback for `/memories`. The original 15-year cap pre-dated
|
||||
/// most of the imported libraries; bumped to 20 so users with deeper
|
||||
/// archives see those photos surface on the matching anniversary too.
|
||||
@@ -382,23 +502,29 @@ pub async fn list_memories(
|
||||
|
||||
let span_mode = q.span.unwrap_or(MemoriesSpan::Day);
|
||||
|
||||
// The SQL filter expects a signed offset in minutes from UTC; default
|
||||
// 0 (UTC) when the client didn't send a hint. We also keep a chrono
|
||||
// `FixedOffset` for sorting/secondary-key date math in Rust below —
|
||||
// anchoring both sides on the same value keeps "what SQL matched" and
|
||||
// "what we sort by" consistent.
|
||||
let tz_offset_minutes = q.timezone_offset_minutes.unwrap_or(0);
|
||||
let client_timezone = q
|
||||
.timezone_offset_minutes
|
||||
.and_then(|offset_mins| FixedOffset::east_opt(offset_mins * 60));
|
||||
// Grouping happens in the owner's display zone (prefs → app default),
|
||||
// not the client's device offset (ADR-0001): a photo taken in Japan at
|
||||
// 21:00 JST belongs to the owner's day, and a container's UTC-rendered
|
||||
// creation time is a true instant. The legacy `timezone_offset_minutes`
|
||||
// param is still accepted on the wire for old clients but no longer
|
||||
// drives grouping.
|
||||
let display_zone_name = {
|
||||
let prefs = app_state
|
||||
.user_ai_prefs_dao
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut dao| dao.get_prefs(&span_context).ok().flatten());
|
||||
crate::camera_tz::display_zone_for(prefs.as_ref().and_then(|p| p.display_tz.as_deref()))
|
||||
};
|
||||
let display_zone =
|
||||
chrono_tz::Tz::from_str(&display_zone_name).unwrap_or(chrono_tz::Tz::America__New_York);
|
||||
|
||||
let items = match gather_memory_items(
|
||||
&app_state,
|
||||
&exif_dao,
|
||||
&span_context,
|
||||
span_mode,
|
||||
tz_offset_minutes,
|
||||
client_timezone,
|
||||
display_zone,
|
||||
q.library.as_deref(),
|
||||
) {
|
||||
Ok(items) => items,
|
||||
@@ -414,7 +540,7 @@ pub async fn list_memories(
|
||||
KeyValue::new("span", format!("{:?}", span_mode)),
|
||||
KeyValue::new("years_back", DEFAULT_YEARS_BACK.to_string()),
|
||||
KeyValue::new("result_count", items.len().to_string()),
|
||||
KeyValue::new("tz_offset_minutes", tz_offset_minutes.to_string()),
|
||||
KeyValue::new("display_tz", display_zone_name),
|
||||
KeyValue::new("excluded_dirs", format!("{:?}", app_state.excluded_dirs)),
|
||||
],
|
||||
);
|
||||
@@ -426,29 +552,41 @@ pub async fn list_memories(
|
||||
/// Resolve an "on this day/week/month across past years" window into an
|
||||
/// ordered list of [`MemoryItem`]s. Shared by the `/memories` handler and the
|
||||
/// memory-reel selector so both honour the same library resolution, per-library
|
||||
/// exclusions, timezone handling, and sort order. Returns `Err(message)` only
|
||||
/// when the `library` param is invalid (callers map that to 400); per-library
|
||||
/// exclusions, and sort order. Returns `Err(message)` only when the
|
||||
/// `library` param is invalid (callers map that to 400); per-library
|
||||
/// query/lock failures are logged and skipped, matching the handler's
|
||||
/// best-effort behaviour.
|
||||
///
|
||||
/// Grouping is done in the owner's display zone (ADR-0001), on the row's
|
||||
/// *true* instant — so a container's UTC-rendered creation time and an
|
||||
/// unconverted EXIF wall clock both land in the same day a photo taken
|
||||
/// locally did. The SQL pass is an interval pre-filter; exact span-day
|
||||
/// matching happens here in Rust via [`date_view`].
|
||||
pub fn gather_memory_items(
|
||||
app_state: &AppState,
|
||||
exif_dao: &Mutex<Box<dyn ExifDao>>,
|
||||
span_context: &opentelemetry::Context,
|
||||
span_mode: MemoriesSpan,
|
||||
tz_offset_minutes: i32,
|
||||
client_timezone: Option<FixedOffset>,
|
||||
display_zone: chrono_tz::Tz,
|
||||
library_param: Option<&str>,
|
||||
) -> Result<Vec<MemoryItem>, String> {
|
||||
let span_token = match span_mode {
|
||||
MemoriesSpan::Day => "day",
|
||||
MemoriesSpan::Week => "week",
|
||||
MemoriesSpan::Month => "month",
|
||||
};
|
||||
let years_back: i32 = DEFAULT_YEARS_BACK;
|
||||
let years_back: i64 = DEFAULT_YEARS_BACK as i64;
|
||||
|
||||
let now = Utc::now();
|
||||
let now_ts = now.timestamp();
|
||||
let today_d = display_zone
|
||||
.from_utc_datetime(&now.naive_utc())
|
||||
.date_naive();
|
||||
// The SQL pass is a coarse pre-filter over the FULL years-back window
|
||||
// (not the current span window) because this feature matches the same
|
||||
// month-day / week / month across every past year; [`day_in_span`] does
|
||||
// the exact matching in Rust (ADR-0001).
|
||||
let (conv_lo, conv_hi, unconv_lo, unconv_hi) = memories_span_windows(now_ts, years_back);
|
||||
let server_zone = crate::camera_tz::server_zone();
|
||||
|
||||
debug!(
|
||||
"gather_memory_items: span={:?} tz_offset_min={} years_back={}",
|
||||
span_mode, tz_offset_minutes, years_back
|
||||
"gather_memory_items: span={:?} display_zone={:?} years_back={}",
|
||||
span_mode, display_zone, years_back
|
||||
);
|
||||
|
||||
let library = crate::libraries::resolve_library_param_state(app_state, library_param)?;
|
||||
@@ -470,9 +608,10 @@ pub fn gather_memory_items(
|
||||
Ok(mut dao) => match dao.get_memories_in_window(
|
||||
span_context,
|
||||
lib.id,
|
||||
span_token,
|
||||
years_back,
|
||||
tz_offset_minutes,
|
||||
conv_lo,
|
||||
conv_hi,
|
||||
unconv_lo,
|
||||
unconv_hi,
|
||||
) {
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
@@ -489,28 +628,37 @@ pub fn gather_memory_items(
|
||||
}
|
||||
};
|
||||
|
||||
for (rel_path, date_taken_ts, last_modified_ts) in rows {
|
||||
for row in rows {
|
||||
// Apply per-library exclusions in Rust — they're a small
|
||||
// set and pushing them into the SQL WHERE adds bind-param
|
||||
// gymnastics with no measurable win at this scale.
|
||||
let full_path = base.join(&rel_path);
|
||||
let full_path = base.join(&row.file_path);
|
||||
if path_excluder.is_excluded(&full_path) {
|
||||
trace!("Memory excluded by PathExcluder: {:?}", full_path);
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(file_date) = date_in_client_tz(date_taken_ts, client_timezone) else {
|
||||
let Some(display_day) =
|
||||
crate::date_view::row_display_day(&row, display_zone, server_zone)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !day_in_span(display_day, today_d, span_mode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let created = crate::date_view::row_true_instant(&row, display_zone, server_zone);
|
||||
memories_with_dates.push((
|
||||
MemoryItem {
|
||||
path: rel_path,
|
||||
created: Some(date_taken_ts),
|
||||
modified: Some(last_modified_ts),
|
||||
path: row.file_path,
|
||||
created,
|
||||
modified: Some(row.last_modified),
|
||||
library_id: lib.id,
|
||||
date_utc: row.date_utc,
|
||||
camera_offset_minutes: row.camera_offset_minutes,
|
||||
date_taken_tz_source: row.date_taken_tz_source,
|
||||
},
|
||||
file_date,
|
||||
display_day,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -782,6 +930,32 @@ mod tests {
|
||||
assert_eq!(date_time.timestamp(), 1383929602);
|
||||
}
|
||||
|
||||
// Pattern-name seam (ticket #117): the audit utility prints which
|
||||
// pattern fired, so every class pattern must report a stable name.
|
||||
#[test]
|
||||
fn test_match_filename_date_reports_pattern_names() {
|
||||
let cases: &[(&str, &str)] = &[
|
||||
("Screenshot_2014-06-01-20-44-50.png", "screenshot"),
|
||||
("Screenshot_20140601_204450.png", "screenshot_compact"),
|
||||
("2015-01-09_02-15-15.jpg", "dash"),
|
||||
("2015-01-09-021515.jpg", "dash_compact"),
|
||||
("20140927101712.jpg", "compact"),
|
||||
("att_1422489664680106.jpeg", "unix_timestamp"),
|
||||
("1401638400.jpeg", "unix_timestamp"),
|
||||
];
|
||||
for (name, pattern) in cases {
|
||||
let match_ =
|
||||
match_filename_date(name).unwrap_or_else(|| panic!("no match for {}", name));
|
||||
assert_eq!(match_.pattern, *pattern, "pattern name for {}", name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_match_filename_date_out_of_class_is_none() {
|
||||
assert_eq!(match_filename_date("IMG_12345.jpg"), None);
|
||||
assert_eq!(match_filename_date("000227580005.jpg"), None);
|
||||
}
|
||||
|
||||
// The obsolete `test_memory_date_priority_*` tests covered the old
|
||||
// request-time waterfall in `get_memory_date_with_priority`. Their
|
||||
// replacement lives in `crate::date_resolver::tests` (resolver
|
||||
@@ -964,6 +1138,9 @@ mod tests {
|
||||
created: Some(jan_15_2024_9am),
|
||||
modified: Some(jan_15_2024_9am),
|
||||
library_id: 1,
|
||||
date_utc: None,
|
||||
camera_offset_minutes: None,
|
||||
date_taken_tz_source: None,
|
||||
},
|
||||
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
|
||||
),
|
||||
@@ -973,6 +1150,9 @@ mod tests {
|
||||
created: Some(jan_15_2020_10am),
|
||||
modified: Some(jan_15_2020_10am),
|
||||
library_id: 1,
|
||||
date_utc: None,
|
||||
camera_offset_minutes: None,
|
||||
date_taken_tz_source: None,
|
||||
},
|
||||
NaiveDate::from_ymd_opt(2020, 1, 15).unwrap(),
|
||||
),
|
||||
@@ -982,6 +1162,9 @@ mod tests {
|
||||
created: Some(jan_16_2021_8am),
|
||||
modified: Some(jan_16_2021_8am),
|
||||
library_id: 1,
|
||||
date_utc: None,
|
||||
camera_offset_minutes: None,
|
||||
date_taken_tz_source: None,
|
||||
},
|
||||
NaiveDate::from_ymd_opt(2021, 1, 16).unwrap(),
|
||||
),
|
||||
@@ -1013,4 +1196,325 @@ mod tests {
|
||||
assert_eq!(memories_with_dates[1].0.created.unwrap(), jan_15_2024_9am);
|
||||
assert_eq!(memories_with_dates[2].0.created.unwrap(), jan_16_2021_8am);
|
||||
}
|
||||
|
||||
// ---- Day-matching (ticket #119) ----
|
||||
|
||||
#[test]
|
||||
fn span_windows_cover_the_full_years_back_range() {
|
||||
// Regression guard for the "current span window only" bug: the
|
||||
// day/week/month spans match across PAST YEARS, so the SQL
|
||||
// pre-filter must span the whole years-back horizon. If this
|
||||
// narrowed to the current day/week/month, /memories would return
|
||||
// only photos taken right now.
|
||||
let now = 1_700_000_000i64;
|
||||
let (clo, chi, ulo, uhi) = memories_span_windows(now, 20);
|
||||
let lo_bound = now - 20 * 365 * 86_400;
|
||||
assert_eq!(clo, lo_bound, "converted window must start at the horizon");
|
||||
assert_eq!(chi, now, "converted window must end at now");
|
||||
assert_eq!(
|
||||
ulo,
|
||||
lo_bound - 15 * 3600,
|
||||
"unconverted band must pad the horizon"
|
||||
);
|
||||
assert_eq!(uhi, now + 15 * 3600, "unconverted band must pad now");
|
||||
// The band subsumes the converted window so the DAO's single
|
||||
// outer bound on legacy date_taken never drops a converted row.
|
||||
assert!(ulo <= clo && chi <= uhi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn day_in_span_day_and_month() {
|
||||
let today = NaiveDate::from_ymd_opt(2024, 7, 4).unwrap();
|
||||
assert!(day_in_span(
|
||||
NaiveDate::from_ymd_opt(2024, 7, 4).unwrap(),
|
||||
today,
|
||||
MemoriesSpan::Day
|
||||
));
|
||||
assert!(!day_in_span(
|
||||
NaiveDate::from_ymd_opt(2024, 7, 5).unwrap(),
|
||||
today,
|
||||
MemoriesSpan::Day
|
||||
));
|
||||
assert!(day_in_span(
|
||||
NaiveDate::from_ymd_opt(2020, 7, 4).unwrap(),
|
||||
today,
|
||||
MemoriesSpan::Day
|
||||
));
|
||||
assert!(day_in_span(
|
||||
NaiveDate::from_ymd_opt(2024, 7, 30).unwrap(),
|
||||
today,
|
||||
MemoriesSpan::Month
|
||||
));
|
||||
assert!(!day_in_span(
|
||||
NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
|
||||
today,
|
||||
MemoriesSpan::Month
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn day_in_span_week_is_monday_based_with_week_zero() {
|
||||
let monday_2024 = NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(); // Mon
|
||||
// Same week: Sunday Jul 7 is still week of Mon Jul 1.
|
||||
assert!(day_in_span(
|
||||
NaiveDate::from_ymd_opt(2024, 7, 7).unwrap(),
|
||||
monday_2024,
|
||||
MemoriesSpan::Week
|
||||
));
|
||||
// Next Monday is the next week.
|
||||
assert!(!day_in_span(
|
||||
NaiveDate::from_ymd_opt(2024, 7, 8).unwrap(),
|
||||
monday_2024,
|
||||
MemoriesSpan::Week
|
||||
));
|
||||
// Previous Sunday is the previous week.
|
||||
assert!(!day_in_span(
|
||||
NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
|
||||
monday_2024,
|
||||
MemoriesSpan::Week
|
||||
));
|
||||
|
||||
// 2022 week 00: Jan 1 (Sat) and Jan 2 (Sun) share week 00; Jan 3
|
||||
// (Mon) starts week 1.
|
||||
let jan1_2022 = NaiveDate::from_ymd_opt(2022, 1, 1).unwrap();
|
||||
assert!(day_in_span(
|
||||
NaiveDate::from_ymd_opt(2022, 1, 2).unwrap(),
|
||||
jan1_2022,
|
||||
MemoriesSpan::Week
|
||||
));
|
||||
assert!(!day_in_span(
|
||||
NaiveDate::from_ymd_opt(2022, 1, 3).unwrap(),
|
||||
jan1_2022,
|
||||
MemoriesSpan::Week
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_w_week_matches_known_values() {
|
||||
// 2024: Jan 1 is a Monday → week 1 starts Jan 1.
|
||||
assert_eq!(
|
||||
sqlite_w_week(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
sqlite_w_week(NaiveDate::from_ymd_opt(2024, 12, 30).unwrap()),
|
||||
53
|
||||
);
|
||||
// 2022: Jan 1 is a Saturday → two-day week 00.
|
||||
assert_eq!(
|
||||
sqlite_w_week(NaiveDate::from_ymd_opt(2022, 1, 1).unwrap()),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
sqlite_w_week(NaiveDate::from_ymd_opt(2022, 1, 2).unwrap()),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
sqlite_w_week(NaiveDate::from_ymd_opt(2022, 1, 3).unwrap()),
|
||||
1
|
||||
);
|
||||
// 2023: Jan 1 is a Sunday → one-day week 00.
|
||||
assert_eq!(
|
||||
sqlite_w_week(NaiveDate::from_ymd_opt(2023, 1, 1).unwrap()),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
sqlite_w_week(NaiveDate::from_ymd_opt(2023, 1, 2).unwrap()),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
sqlite_w_week(NaiveDate::from_ymd_opt(2023, 12, 31).unwrap()),
|
||||
52
|
||||
);
|
||||
}
|
||||
|
||||
/// AC-2 / AC-3 / AC-7: seam test with mixed fixtures — a converted
|
||||
/// row and two unconverted rows (EXIF wall convention + container UTC
|
||||
/// convention) all group into the owner's display-zone month, and each
|
||||
/// item carries its true instant under the per-source convention.
|
||||
mod api {
|
||||
use super::*;
|
||||
use crate::database::SqliteExifDao;
|
||||
use crate::database::models::InsertImageExif;
|
||||
use crate::database::test::in_memory_db_connection;
|
||||
|
||||
fn seed_row(dao: &mut SqliteExifDao, rel: &str, date_taken: i64, source: &str) {
|
||||
dao.store_exif(
|
||||
&opentelemetry::Context::new(),
|
||||
InsertImageExif {
|
||||
library_id: 1,
|
||||
file_path: rel.to_string(),
|
||||
camera_make: None,
|
||||
camera_model: None,
|
||||
lens_model: None,
|
||||
width: None,
|
||||
height: None,
|
||||
orientation: None,
|
||||
gps_latitude: None,
|
||||
gps_longitude: None,
|
||||
gps_altitude: None,
|
||||
focal_length: None,
|
||||
aperture: None,
|
||||
shutter_speed: None,
|
||||
iso: None,
|
||||
date_taken: Some(date_taken),
|
||||
created_time: 0,
|
||||
last_modified: 0,
|
||||
content_hash: None,
|
||||
size_bytes: None,
|
||||
phash_64: None,
|
||||
dhash_64: None,
|
||||
date_taken_source: Some(source.to_string()),
|
||||
date_utc: None,
|
||||
camera_offset_minutes: None,
|
||||
date_taken_tz_source: None,
|
||||
},
|
||||
)
|
||||
.expect("insert row");
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn memories_mixed_rows_group_into_owner_month() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let state = AppState::test_state();
|
||||
let zone = chrono_tz::Tz::America__New_York;
|
||||
|
||||
// Month span over the 5th of the CURRENT month in past years,
|
||||
// at 12:00 naive: under all three conventions (camera wall in
|
||||
// the server zone, container UTC, converted instant) the
|
||||
// grouping day can't roll across midnight, so the fixture is
|
||||
// stable across DST transitions and leap years.
|
||||
let now = Utc::now();
|
||||
let (month, year) = (now.month(), now.year());
|
||||
let at_12 = |y: i32| {
|
||||
NaiveDate::from_ymd_opt(y, month, 5)
|
||||
.unwrap()
|
||||
.and_hms_opt(12, 0, 0)
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let mut dao = SqliteExifDao::from_connection(in_memory_db_connection());
|
||||
|
||||
// 1. Converted row: true instant 12:00 UTC (no camera offset).
|
||||
let conv_instant = at_12(year - 3).and_utc().timestamp();
|
||||
seed_row(&mut dao, "conv.jpg", conv_instant, "exif");
|
||||
dao.backfill_date_taken(
|
||||
&opentelemetry::Context::new(),
|
||||
1,
|
||||
"conv.jpg",
|
||||
conv_instant,
|
||||
"exif",
|
||||
Some(conv_instant),
|
||||
None,
|
||||
Some("utc_direct"),
|
||||
)
|
||||
.expect("convert row");
|
||||
|
||||
// 2. Unconverted EXIF row: legacy is the camera wall read as
|
||||
// UTC — the true moment is 12:00 in the server zone.
|
||||
let exif_wall = at_12(year - 1);
|
||||
let exif_legacy = exif_wall.and_utc().timestamp();
|
||||
seed_row(&mut dao, "unconv-exif.jpg", exif_legacy, "exif");
|
||||
|
||||
// 3. Unconverted container row: legacy is the raw UTC creation
|
||||
// time string rendered in the server zone (the old server
|
||||
// read the epoch as a naive string and stamped it through
|
||||
// its local zone). Pre-#115 this row double-shifted in
|
||||
// /memories; it must now land in the same owner month as
|
||||
// the other two.
|
||||
let vid_legacy = at_12(year - 2).and_utc().timestamp();
|
||||
seed_row(&mut dao, "unconv-vid.mp4", vid_legacy, "exiftool");
|
||||
|
||||
let exif_mutex: Mutex<Box<dyn ExifDao>> = Mutex::new(Box::new(dao));
|
||||
let items = gather_memory_items(
|
||||
&state,
|
||||
&exif_mutex,
|
||||
&opentelemetry::Context::new(),
|
||||
MemoriesSpan::Month,
|
||||
zone,
|
||||
None,
|
||||
)
|
||||
.expect("gather");
|
||||
let by_path = |p: &str| items.iter().find(|it| it.path == p);
|
||||
|
||||
// All three rows group into the same owner month (AC-2/AC-3).
|
||||
let conv = by_path("conv.jpg").expect("converted row must be returned");
|
||||
let exif_row =
|
||||
by_path("unconv-exif.jpg").expect("unconverted EXIF row must be returned");
|
||||
let vid =
|
||||
by_path("unconv-vid.mp4").expect("unconverted container row must be returned");
|
||||
|
||||
// AC-1: the triple rides along — populated for the converted
|
||||
// row, None for the unconverted ones.
|
||||
assert_eq!(conv.date_utc, Some(conv_instant));
|
||||
assert_eq!(conv.camera_offset_minutes, None);
|
||||
assert_eq!(conv.date_taken_tz_source.as_deref(), Some("utc_direct"));
|
||||
assert_eq!(exif_row.date_utc, None);
|
||||
assert_eq!(exif_row.camera_offset_minutes, None);
|
||||
assert_eq!(exif_row.date_taken_tz_source, None);
|
||||
assert_eq!(vid.date_utc, None);
|
||||
|
||||
// AC-2: `created` is the true instant under each row's
|
||||
// per-source convention. The server zone (the zone the old
|
||||
// server rendered legacy values in) is a deploy property, so
|
||||
// the expected values are derived from the convention, not
|
||||
// hardcoded.
|
||||
let server_zone = crate::camera_tz::server_zone();
|
||||
let legacy_as_wall = |ts: i64| {
|
||||
chrono::DateTime::from_timestamp(ts, 0)
|
||||
.expect("valid epoch")
|
||||
.naive_utc()
|
||||
};
|
||||
// exif: camera wall reinterpreted in the server zone.
|
||||
let exif_expected = match server_zone {
|
||||
Some(z) => z
|
||||
.from_local_datetime(&legacy_as_wall(exif_legacy))
|
||||
.single()
|
||||
.expect("camera wall in server zone")
|
||||
.timestamp(),
|
||||
None => exif_legacy,
|
||||
};
|
||||
// exiftool video: raw UTC naive rendered in the server zone,
|
||||
// read back as UTC.
|
||||
let vid_expected = match server_zone {
|
||||
Some(z) => z
|
||||
.timestamp_opt(vid_legacy, 0)
|
||||
.single()
|
||||
.expect("server-zone render")
|
||||
.naive_local()
|
||||
.and_utc()
|
||||
.timestamp(),
|
||||
None => vid_legacy,
|
||||
};
|
||||
|
||||
assert_eq!(conv.created, Some(conv_instant));
|
||||
assert_eq!(exif_row.created, Some(exif_expected));
|
||||
assert_eq!(vid.created, Some(vid_expected));
|
||||
}
|
||||
|
||||
/// AC-1 (response shape): the serialized `/memories` item carries
|
||||
/// the new-contract triple as explicit fields — null while the row
|
||||
/// is unconverted.
|
||||
#[test]
|
||||
fn memories_response_shape_carries_triple() {
|
||||
let item = MemoryItem {
|
||||
path: "a.jpg".into(),
|
||||
created: Some(1_700_000_000),
|
||||
modified: Some(0),
|
||||
library_id: 1,
|
||||
date_utc: None,
|
||||
camera_offset_minutes: None,
|
||||
date_taken_tz_source: None,
|
||||
};
|
||||
let v: serde_json::Value =
|
||||
serde_json::to_value(&MemoriesResponse { items: vec![item] }).unwrap();
|
||||
let it = &v["items"][0];
|
||||
assert!(it.get("date_utc").is_some() && it["date_utc"].is_null());
|
||||
assert!(
|
||||
it.get("camera_offset_minutes").is_some() && it["camera_offset_minutes"].is_null()
|
||||
);
|
||||
assert!(it.get("date_taken_tz_source").is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
+126
-1
@@ -2,7 +2,9 @@ use actix_web::HttpRequest;
|
||||
use actix_web::http::header::HeaderMap;
|
||||
use opentelemetry::global::{BoxedSpan, BoxedTracer};
|
||||
use opentelemetry::propagation::TextMapPropagator;
|
||||
use opentelemetry::trace::{Span, Status, Tracer};
|
||||
use opentelemetry::trace::{
|
||||
Span, SpanBuilder, SpanContext, SpanKind, Status, TraceContextExt, Tracer,
|
||||
};
|
||||
use opentelemetry::{Context, KeyValue, global};
|
||||
use opentelemetry_appender_log::OpenTelemetryLogBridge;
|
||||
use opentelemetry_otlp::WithExportConfig;
|
||||
@@ -77,11 +79,78 @@ impl<'a> opentelemetry::propagation::Extractor for HeaderExtractor<'a> {
|
||||
self.0.keys().map(|k| k.as_str()).collect()
|
||||
}
|
||||
}
|
||||
/// Start a client span for a single LLM call. Every provider funnels its
|
||||
/// LlmClient methods through this so the span shape (name, `SpanKind::Client`,
|
||||
/// provider / operation / model attributes) is identical across
|
||||
/// openrouter / llamacpp / ollama. `operation` is one of `chat`,
|
||||
/// `chat_stream`, `embeddings`, `describe_image`, `generate`.
|
||||
pub fn start_llm_request_span(
|
||||
cx: &Context,
|
||||
provider: &str,
|
||||
operation: &str,
|
||||
model: &str,
|
||||
) -> BoxedSpan {
|
||||
let builder = SpanBuilder::from_name("llm.request").with_kind(SpanKind::Client);
|
||||
let mut span = global_tracer().build_with_context(builder, cx);
|
||||
span.set_attribute(KeyValue::new("llm.provider", provider.to_string()));
|
||||
span.set_attribute(KeyValue::new("llm.operation", operation.to_string()));
|
||||
span.set_attribute(KeyValue::new("llm.model", model.to_string()));
|
||||
span
|
||||
}
|
||||
|
||||
pub fn extract_context_from_request(req: &HttpRequest) -> Context {
|
||||
let propagator = TraceContextPropagator::new();
|
||||
propagator.extract(&HeaderExtractor(req.headers()))
|
||||
}
|
||||
|
||||
/// Build the W3C trace-context headers to attach to an outgoing HTTP
|
||||
/// request so a downstream service (llama-swap, Ollama) can parent its own
|
||||
/// spans onto this trace. Returns an empty vec when the context carries no
|
||||
/// valid span — e.g. debug builds, where the global tracer is a no-op and
|
||||
/// injecting an all-zero traceparent would produce garbage parent spans.
|
||||
pub fn traceparent_headers(context: &Context) -> Vec<(String, String)> {
|
||||
if !context.span().span_context().is_valid() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut headers: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
||||
TraceContextPropagator::new().inject_context(context, &mut headers);
|
||||
// The propagator writes an empty tracestate header when there is no
|
||||
// state — drop empty-valued headers rather than sending noise.
|
||||
headers.into_iter().filter(|(_, v)| !v.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// Attach the W3C traceparent header to an outgoing request so a downstream
|
||||
/// service parents its own spans onto this trace. No-op when the span context
|
||||
/// is not valid — e.g. debug builds, where the global tracer is a no-op and an
|
||||
/// all-zero traceparent would produce garbage parent spans.
|
||||
pub fn with_traceparent(
|
||||
builder: reqwest::RequestBuilder,
|
||||
span_context: &SpanContext,
|
||||
) -> reqwest::RequestBuilder {
|
||||
let mut b = builder;
|
||||
for (k, v) in
|
||||
traceparent_headers(&Context::new().with_remote_span_context(span_context.clone()))
|
||||
{
|
||||
b = b.header(k, v);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Record the HTTP status code on the span (success or failure path).
|
||||
pub fn set_http_status(span: Option<&mut BoxedSpan>, status: u16) {
|
||||
if let Some(s) = span {
|
||||
s.set_attribute(KeyValue::new("http.status_code", status as i64));
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an HTTP failure on the span: status code plus an error status.
|
||||
pub fn record_http_failure(span: Option<&mut BoxedSpan>, status: u16, message: &str) {
|
||||
if let Some(s) = span {
|
||||
s.set_attribute(KeyValue::new("http.status_code", status as i64));
|
||||
s.set_status(Status::error(message.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trace_db_call<F, O>(
|
||||
context: &Context,
|
||||
query_type: &str,
|
||||
@@ -110,3 +179,59 @@ where
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod traceparent_tests {
|
||||
use super::*;
|
||||
use opentelemetry::trace::{
|
||||
SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId, TraceState,
|
||||
};
|
||||
|
||||
/// A context carrying a span context with known ids — no global
|
||||
/// provider needed, so tests can't pollute process state.
|
||||
fn context_with_flags(flags: TraceFlags) -> Context {
|
||||
let trace_id = TraceId::from_bytes([
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
|
||||
0x0f, 0x10,
|
||||
]);
|
||||
let span_id = SpanId::from_bytes([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
|
||||
let span_context = SpanContext::new(trace_id, span_id, flags, false, TraceState::default());
|
||||
Context::new().with_remote_span_context(span_context)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traceparent_headers_sampled_context() {
|
||||
let cx = context_with_flags(TraceFlags::SAMPLED);
|
||||
let headers = traceparent_headers(&cx);
|
||||
assert_eq!(
|
||||
headers,
|
||||
vec![(
|
||||
"traceparent".to_string(),
|
||||
"00-0102030405060708090a0b0c0d0e0f10-0102030405060708-01".to_string(),
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traceparent_headers_not_sampled_context() {
|
||||
// A valid-but-unsampled context still propagates; the flags byte
|
||||
// carries the state.
|
||||
let cx = context_with_flags(TraceFlags::NOT_SAMPLED);
|
||||
let headers = traceparent_headers(&cx);
|
||||
assert_eq!(
|
||||
headers,
|
||||
vec![(
|
||||
"traceparent".to_string(),
|
||||
"00-0102030405060708090a0b0c0d0e0f10-0102030405060708-00".to_string(),
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traceparent_headers_invalid_context_is_empty() {
|
||||
// No span (debug-build no-op tracer) → no header, never an
|
||||
// all-zero traceparent.
|
||||
let cx = Context::new();
|
||||
assert!(traceparent_headers(&cx).is_empty());
|
||||
}
|
||||
}
|
||||
+1066
File diff suppressed because it is too large.
Load diff
+196
-30
@@ -18,6 +18,7 @@ pub mod selector;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::sync::{LazyLock, Mutex, Mutex as StdMutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -71,13 +72,38 @@ pub fn normalize_library_key(libs: &[Library], param: Option<&str>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reel span / segment-cap resolution: explicit request param → stored
|
||||
/// owner pref → hard default. A stored span that no longer parses (e.g.
|
||||
/// hand-edited) is skipped rather than erroring — the hard default still
|
||||
/// applies. Non-positive stored segment caps are ignored the same way.
|
||||
fn resolve_reel_params(
|
||||
req_span: Option<MemoriesSpan>,
|
||||
req_max_segments: Option<usize>,
|
||||
prefs: Option<&crate::database::models::UserAiPrefs>,
|
||||
) -> (MemoriesSpan, usize) {
|
||||
let pref_span = prefs
|
||||
.and_then(|p| p.reel_span.as_deref())
|
||||
.and_then(|s| MemoriesSpan::from_str(s).ok());
|
||||
let pref_max_segments = prefs
|
||||
.and_then(|p| p.reel_max_segments)
|
||||
.filter(|v| *v > 0)
|
||||
.map(|v| v as usize);
|
||||
let span = req_span.or(pref_span).unwrap_or(MemoriesSpan::Day);
|
||||
let max_segments = req_max_segments
|
||||
.or(pref_max_segments)
|
||||
.unwrap_or(selector::DEFAULT_MAX_SEGMENTS);
|
||||
(span, max_segments)
|
||||
}
|
||||
|
||||
/// Best-effort: mirror the latest client reel params into `user_ai_prefs`
|
||||
/// so the nightly pre-gen scheduler can pick them up. Never fails the
|
||||
/// caller regardless of DB errors.
|
||||
fn capture_prefs(
|
||||
app_state: &AppState,
|
||||
req: &web::Json<CreateReelRequest>,
|
||||
req: &CreateReelRequest,
|
||||
library_param: Option<&str>,
|
||||
effective_span: MemoriesSpan,
|
||||
effective_max_segments: usize,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
use crate::database::models::UpsertUserAiPrefs;
|
||||
let now = std::time::SystemTime::now()
|
||||
@@ -93,6 +119,21 @@ fn capture_prefs(
|
||||
};
|
||||
let mut dao = app_state.user_ai_prefs_dao.lock().expect("lock");
|
||||
let ctx = opentelemetry::Context::new();
|
||||
// The row passively mirrors the latest request's EFFECTIVE reel params
|
||||
// (after the request → pref → default resolution done by the caller), so
|
||||
// the stored reel_span / reel_max_segments track what was last used —
|
||||
// idempotent when the pref itself supplied the effective value.
|
||||
//
|
||||
// The owner-default fields (model/backend/persona) are owned by PUT /prefs,
|
||||
// not by reel requests. The upsert overwrites every column, so we must
|
||||
// carry the existing values through unchanged — writing None here would
|
||||
// silently wipe the owner's stored defaults on every reel creation. If the
|
||||
// read fails we bail without writing: this is best-effort, and clobbering
|
||||
// owner defaults on a transient error is worse than skipping a mirror.
|
||||
let existing = match dao.get_prefs(&ctx) {
|
||||
Ok(row) => row,
|
||||
Err(e) => return Err(anyhow::anyhow!("failed to read prefs before mirror: {e}")),
|
||||
};
|
||||
dao.upsert_prefs(
|
||||
&ctx,
|
||||
&UpsertUserAiPrefs {
|
||||
@@ -104,6 +145,11 @@ fn capture_prefs(
|
||||
library: Some(library),
|
||||
updated_at: now as i64,
|
||||
display_tz: req.display_tz.clone().filter(|s| !s.is_empty()),
|
||||
default_model: existing.as_ref().and_then(|e| e.default_model.clone()),
|
||||
default_backend: existing.as_ref().and_then(|e| e.default_backend.clone()),
|
||||
default_persona_id: existing.as_ref().and_then(|e| e.default_persona_id.clone()),
|
||||
reel_span: Some(effective_span.to_string()),
|
||||
reel_max_segments: Some(effective_max_segments as i32),
|
||||
},
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("failed to upsert user_ai_prefs: {e}"))
|
||||
@@ -415,11 +461,26 @@ pub async fn create_reel_handler(
|
||||
}));
|
||||
}
|
||||
|
||||
let span = req.span.unwrap_or(MemoriesSpan::Day);
|
||||
let max_segments = req.max_segments.unwrap_or(selector::DEFAULT_MAX_SEGMENTS);
|
||||
// Owner defaults: explicit request param → stored pref → hard default.
|
||||
// The stored row is read once and reused for the span, the segment cap,
|
||||
// and the display zone. The legacy device-offset param is still mirrored
|
||||
// into the frozen prefs column by `capture_prefs` for rollback but no
|
||||
// longer drives selection.
|
||||
let stored_prefs = app_state
|
||||
.user_ai_prefs_dao
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut dao| dao.get_prefs(&opentelemetry::Context::new()).ok().flatten());
|
||||
let (span, max_segments) =
|
||||
resolve_reel_params(req.span, req.max_segments, stored_prefs.as_ref());
|
||||
let display_zone_name = {
|
||||
let req_tz = req.display_tz.clone().filter(|s| !s.trim().is_empty());
|
||||
let stored = stored_prefs.as_ref().and_then(|p| p.display_tz.clone());
|
||||
crate::camera_tz::display_zone_for(req_tz.as_deref().or(stored.as_deref()))
|
||||
};
|
||||
let selector = ReelSelector::Memories {
|
||||
span,
|
||||
tz_offset_minutes: req.timezone_offset_minutes.unwrap_or(0),
|
||||
display_tz: display_zone_name,
|
||||
library: req.library.clone(),
|
||||
max_segments,
|
||||
};
|
||||
@@ -475,7 +536,7 @@ pub async fn create_reel_handler(
|
||||
},
|
||||
);
|
||||
// Capture params for passive prefs mirror (best-effort, never fails).
|
||||
let _ = capture_prefs(&app_state, &req, req.library.as_deref());
|
||||
let _ = capture_prefs(&app_state, &req, req.library.as_deref(), span, max_segments);
|
||||
return HttpResponse::Accepted().json(ReelJobCreatedResponse {
|
||||
job_id: job_id.to_string(),
|
||||
status: ReelJobStatus::Done,
|
||||
@@ -535,7 +596,7 @@ pub async fn create_reel_handler(
|
||||
with_job(job_id, |job| job.abort = Some(handle.abort_handle()));
|
||||
|
||||
// Capture params for passive prefs mirror (best-effort, never fails).
|
||||
let _ = capture_prefs(&app_state, &req, req.library.as_deref());
|
||||
let _ = capture_prefs(&app_state, &req, req.library.as_deref(), span, max_segments);
|
||||
|
||||
HttpResponse::Accepted().json(ReelJobCreatedResponse {
|
||||
job_id: job_id.to_string(),
|
||||
@@ -965,23 +1026,26 @@ pub(crate) fn secs_until_next_run_hour(now: chrono::DateTime<chrono::Local>, run
|
||||
}
|
||||
|
||||
/// Load pre-gen parameters: tries the user_ai_prefs DB row first, falls back
|
||||
/// to env vars, then to server-local defaults.
|
||||
fn load_pregen_params(app_state: &AppState) -> (i32, Option<String>, String) {
|
||||
/// to env vars, then to the app default display zone. Returns the display
|
||||
/// zone as an IANA name (ADR-0001) — `REEL_PREGEN_DISPLAY_TZ` (e.g.
|
||||
/// "America/New_York") overrides the stored pref when set. IANA zones are
|
||||
/// DST-proof, unlike the old fixed-offset env vars.
|
||||
fn load_pregen_params(app_state: &AppState) -> (String, Option<String>, String) {
|
||||
// Try DB row first
|
||||
if let Ok(mut dao) = app_state.user_ai_prefs_dao.lock() {
|
||||
let ctx = opentelemetry::Context::new();
|
||||
if let Ok(Some(prefs)) = dao.get_prefs(&ctx) {
|
||||
let tz = prefs.tz_offset_minutes.unwrap_or_else(fixed_tz_offset);
|
||||
let tz = crate::camera_tz::display_zone_for(prefs.display_tz.as_deref());
|
||||
let voice = prefs.voice;
|
||||
let library = prefs.library.unwrap_or_else(|| "all".to_string());
|
||||
return (tz, voice, library);
|
||||
}
|
||||
}
|
||||
// Fall back to env (explicit offset overrides auto-detect)
|
||||
let tz = std::env::var("REEL_PREGEN_TZ_OFFSET_MINUTES")
|
||||
// Fall back to env (explicit zone overrides auto-detect)
|
||||
let tz = std::env::var("REEL_PREGEN_DISPLAY_TZ")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or_else(fixed_tz_offset);
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.unwrap_or_else(|| crate::camera_tz::display_zone_for(None));
|
||||
let voice = std::env::var("REEL_PREGEN_VOICE").ok();
|
||||
let library = std::env::var("REEL_PREGEN_LIBRARY")
|
||||
.ok()
|
||||
@@ -989,17 +1053,6 @@ fn load_pregen_params(app_state: &AppState) -> (i32, Option<String>, String) {
|
||||
(tz, voice, library)
|
||||
}
|
||||
|
||||
/// Fixed timezone offset: reads `REEL_PREGEN_TZ_FIXED_MINUTES` (e.g. "-480"
|
||||
/// for US Eastern) when set, falling back to the system local offset. Using
|
||||
/// a fixed offset avoids DST shifts changing the pre-gen schedule halfway
|
||||
/// through the year.
|
||||
fn fixed_tz_offset() -> i32 {
|
||||
std::env::var("REEL_PREGEN_TZ_FIXED_MINUTES")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse().ok())
|
||||
.unwrap_or_else(|| chrono::Local::now().offset().local_minus_utc())
|
||||
}
|
||||
|
||||
/// Spawn the nightly pre-generation scheduler. Runs behind `REEL_PREGEN_ENABLED`.
|
||||
pub(crate) async fn spawn_pregen_scheduler(app_state: web::Data<AppState>) {
|
||||
if std::env::var("REEL_PREGEN_ENABLED").ok() != Some("1".to_string()) {
|
||||
@@ -1044,7 +1097,7 @@ async fn run_pregen_batch(app_state: &AppState) -> anyhow::Result<()> {
|
||||
let (tz, voice, library) = load_pregen_params(app_state);
|
||||
|
||||
for span in spans {
|
||||
if let Err(e) = pregen_one(app_state, span, tz, voice.clone(), &library).await {
|
||||
if let Err(e) = pregen_one(app_state, span, &tz, voice.clone(), &library).await {
|
||||
log::error!("Pre-gen failed for span={}: {}", span, e);
|
||||
}
|
||||
}
|
||||
@@ -1056,7 +1109,7 @@ async fn run_pregen_batch(app_state: &AppState) -> anyhow::Result<()> {
|
||||
async fn pregen_one(
|
||||
app_state: &AppState,
|
||||
span: &str,
|
||||
tz: i32,
|
||||
display_tz: &str,
|
||||
voice: Option<String>,
|
||||
library: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
@@ -1069,7 +1122,7 @@ async fn pregen_one(
|
||||
|
||||
let selector = ReelSelector::Memories {
|
||||
span: memories_span,
|
||||
tz_offset_minutes: tz,
|
||||
display_tz: display_tz.to_string(),
|
||||
library: if library == "all" {
|
||||
None
|
||||
} else {
|
||||
@@ -1179,7 +1232,8 @@ async fn pregen_one(
|
||||
title,
|
||||
media_count,
|
||||
render_version: RENDER_VERSION as i32,
|
||||
tz_offset_minutes: tz,
|
||||
tz_offset_minutes: crate::camera_tz::display_zone_offset_minutes(display_tz, now)
|
||||
.unwrap_or(0) as i32,
|
||||
voice: voice.clone(),
|
||||
generated_at: now,
|
||||
},
|
||||
@@ -1326,9 +1380,121 @@ fn sweep_reel_cache(app_state: &AppState) -> usize {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::models::UserAiPrefs;
|
||||
use crate::libraries::Library;
|
||||
use chrono::TimeZone;
|
||||
|
||||
fn prefs_row(span: Option<&str>, max_segments: Option<i32>) -> UserAiPrefs {
|
||||
UserAiPrefs {
|
||||
id: 1,
|
||||
voice: None,
|
||||
tz_offset_minutes: None,
|
||||
library: None,
|
||||
updated_at: 0,
|
||||
display_tz: None,
|
||||
default_model: None,
|
||||
default_backend: None,
|
||||
default_persona_id: None,
|
||||
reel_span: span.map(str::to_string),
|
||||
reel_max_segments: max_segments,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_reel_params_request_wins_over_pref() {
|
||||
let prefs = prefs_row(Some("month"), Some(10));
|
||||
let (span, max) = resolve_reel_params(Some(MemoriesSpan::Day), Some(5), Some(&prefs));
|
||||
assert_eq!(span, MemoriesSpan::Day);
|
||||
assert_eq!(max, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_reel_params_pref_fills_omitted_request() {
|
||||
let prefs = prefs_row(Some("week"), Some(24));
|
||||
let (span, max) = resolve_reel_params(None, None, Some(&prefs));
|
||||
assert_eq!(span, MemoriesSpan::Week);
|
||||
assert_eq!(max, 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_reel_params_no_pref_falls_to_hard_defaults() {
|
||||
let (span, max) = resolve_reel_params(None, None, None);
|
||||
assert_eq!(span, MemoriesSpan::Day);
|
||||
assert_eq!(max, selector::DEFAULT_MAX_SEGMENTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_reel_params_invalid_or_nonpositive_pref_skipped() {
|
||||
// Hand-edited / corrupt stored values fall through to hard defaults
|
||||
// instead of erroring the request.
|
||||
let bad_span = prefs_row(Some("decade"), Some(0));
|
||||
let (span, max) = resolve_reel_params(None, None, Some(&bad_span));
|
||||
assert_eq!(span, MemoriesSpan::Day);
|
||||
assert_eq!(max, selector::DEFAULT_MAX_SEGMENTS);
|
||||
}
|
||||
|
||||
/// Regression: reel creation is a PASSIVE mirror. The upsert overwrites
|
||||
/// every column, so `capture_prefs` must carry the PUT /prefs-owned
|
||||
/// default_model / default_backend / default_persona_id through unchanged
|
||||
/// — a reel request has no business wiping the owner's AI defaults.
|
||||
#[actix_rt::test]
|
||||
async fn reel_capture_preserves_owner_defaults() {
|
||||
use crate::database::models::UpsertUserAiPrefs;
|
||||
use crate::database::{SqliteUserAiPrefsDao, UserAiPrefsDao};
|
||||
use crate::state::AppState;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
let conn = Arc::new(Mutex::new(crate::database::test::in_memory_db_connection()));
|
||||
let mut state = AppState::test_state();
|
||||
state.user_ai_prefs_dao = Arc::new(Mutex::new(Box::new(
|
||||
SqliteUserAiPrefsDao::from_connection(conn.clone()),
|
||||
)));
|
||||
{
|
||||
let mut dao = SqliteUserAiPrefsDao::from_connection(conn.clone());
|
||||
dao.upsert_prefs(
|
||||
&opentelemetry::Context::new(),
|
||||
&UpsertUserAiPrefs {
|
||||
voice: None,
|
||||
tz_offset_minutes: None,
|
||||
library: None,
|
||||
updated_at: 0,
|
||||
display_tz: None,
|
||||
default_model: Some("llama3.2:3b".into()),
|
||||
default_backend: Some("hybrid".into()),
|
||||
default_persona_id: Some("cabin".into()),
|
||||
reel_span: None,
|
||||
reel_max_segments: None,
|
||||
},
|
||||
)
|
||||
.expect("seed owner defaults");
|
||||
}
|
||||
|
||||
let req = CreateReelRequest {
|
||||
span: Some(MemoriesSpan::Week),
|
||||
timezone_offset_minutes: Some(-240),
|
||||
display_tz: None,
|
||||
library: None,
|
||||
voice: Some("grandma".into()),
|
||||
max_segments: Some(12),
|
||||
};
|
||||
capture_prefs(&state, &req, None, MemoriesSpan::Week, 12).expect("capture");
|
||||
|
||||
let row = state
|
||||
.user_ai_prefs_dao
|
||||
.lock()
|
||||
.expect("lock")
|
||||
.get_prefs(&opentelemetry::Context::new())
|
||||
.expect("read")
|
||||
.expect("row");
|
||||
// The mirrored reel params landed...
|
||||
assert_eq!(row.reel_span.as_deref(), Some("week"));
|
||||
assert_eq!(row.reel_max_segments, Some(12));
|
||||
// ...and the owner defaults survived untouched.
|
||||
assert_eq!(row.default_model.as_deref(), Some("llama3.2:3b"));
|
||||
assert_eq!(row.default_backend.as_deref(), Some("hybrid"));
|
||||
assert_eq!(row.default_persona_id.as_deref(), Some("cabin"));
|
||||
}
|
||||
|
||||
fn photo(p: &str, lib: i32) -> SegmentMedia {
|
||||
SegmentMedia::Photo {
|
||||
rel_path: p.to_string(),
|
||||
@@ -1346,7 +1512,7 @@ mod tests {
|
||||
fn day_selector() -> ReelSelector {
|
||||
ReelSelector::Memories {
|
||||
span: MemoriesSpan::Day,
|
||||
tz_offset_minutes: 0,
|
||||
display_tz: "America/New_York".to_string(),
|
||||
library: None,
|
||||
max_segments: 24,
|
||||
}
|
||||
@@ -1379,7 +1545,7 @@ mod tests {
|
||||
// Span matters.
|
||||
let week = ReelSelector::Memories {
|
||||
span: MemoriesSpan::Week,
|
||||
tz_offset_minutes: 0,
|
||||
display_tz: "America/New_York".to_string(),
|
||||
library: None,
|
||||
max_segments: 24,
|
||||
};
|
||||
|
||||
+2
-1
@@ -281,8 +281,9 @@ pub async fn generate_script(
|
||||
beats: &[PlannedBeat],
|
||||
) -> Result<ReelScript> {
|
||||
let (system, user) = build_script_messages(meta, beats);
|
||||
let cx = opentelemetry::Context::current();
|
||||
let raw = client
|
||||
.generate(&user, Some(&system), None)
|
||||
.generate(&cx, &user, Some(&system), None)
|
||||
.await
|
||||
.context("LLM script generation failed")?;
|
||||
Ok(parse_script_response(&raw, beats.len()))
|
||||
|
||||
+34
-18
@@ -9,9 +9,10 @@
|
||||
//! runs in the background job.
|
||||
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use chrono::{DateTime, Datelike, FixedOffset};
|
||||
use chrono::{Datelike, TimeZone};
|
||||
|
||||
use super::{PlannedBeat, ReelMeta, SegmentMedia};
|
||||
use crate::database::{ExifDao, InsightDao};
|
||||
@@ -64,7 +65,10 @@ pub fn budget_segments(requested_max: usize) -> usize {
|
||||
pub enum ReelSelector {
|
||||
Memories {
|
||||
span: MemoriesSpan,
|
||||
tz_offset_minutes: i32,
|
||||
/// Owner's display zone as an IANA name (ADR-0001) — grouping is
|
||||
/// zone-anchored, not device-offset-anchored, so DST shifts are
|
||||
/// absorbed instead of silently re-bucketing the reel.
|
||||
display_tz: String,
|
||||
library: Option<String>,
|
||||
max_segments: usize,
|
||||
},
|
||||
@@ -78,13 +82,13 @@ impl ReelSelector {
|
||||
match self {
|
||||
ReelSelector::Memories {
|
||||
span,
|
||||
tz_offset_minutes,
|
||||
display_tz,
|
||||
library,
|
||||
max_segments,
|
||||
} => format!(
|
||||
"memories:span={:?}:tz={}:lib={}:max={}",
|
||||
span,
|
||||
tz_offset_minutes,
|
||||
display_tz,
|
||||
library.as_deref().unwrap_or("all"),
|
||||
max_segments
|
||||
),
|
||||
@@ -281,24 +285,24 @@ pub fn resolve(
|
||||
match selector {
|
||||
ReelSelector::Memories {
|
||||
span,
|
||||
tz_offset_minutes,
|
||||
display_tz,
|
||||
library,
|
||||
max_segments,
|
||||
} => {
|
||||
let client_tz = FixedOffset::east_opt(tz_offset_minutes * 60);
|
||||
let zone =
|
||||
chrono_tz::Tz::from_str(display_tz).unwrap_or(chrono_tz::Tz::America__New_York);
|
||||
let items = memories::gather_memory_items(
|
||||
app_state,
|
||||
exif_dao,
|
||||
span_context,
|
||||
*span,
|
||||
*tz_offset_minutes,
|
||||
client_tz,
|
||||
zone,
|
||||
library.as_deref(),
|
||||
)?;
|
||||
|
||||
// Split into photos and video clips; anything that's neither is
|
||||
// dropped. Years span both, computed before the budget narrows it.
|
||||
let years = distinct_years(&items, client_tz);
|
||||
let years = distinct_years(&items, zone);
|
||||
let meta = ReelMeta { span: *span, years };
|
||||
|
||||
let (photos, videos): (Vec<_>, Vec<_>) = items
|
||||
@@ -321,15 +325,12 @@ pub fn resolve(
|
||||
|
||||
/// Distinct calendar years represented by the selected media, in the client's
|
||||
/// timezone, ascending. Used to tell the scripter how far back the reel reaches.
|
||||
fn distinct_years(items: &[memories::MemoryItem], tz: Option<FixedOffset>) -> Vec<i32> {
|
||||
fn distinct_years(items: &[memories::MemoryItem], zone: chrono_tz::Tz) -> Vec<i32> {
|
||||
let mut years: Vec<i32> = items
|
||||
.iter()
|
||||
.filter_map(|it| it.created)
|
||||
.filter_map(|ts| DateTime::from_timestamp(ts, 0))
|
||||
.map(|dt| match tz {
|
||||
Some(off) => dt.with_timezone(&off).year(),
|
||||
None => dt.year(),
|
||||
})
|
||||
.filter_map(|ts| zone.timestamp_opt(ts, 0).single())
|
||||
.map(|dt| dt.year())
|
||||
.collect();
|
||||
years.sort_unstable();
|
||||
years.dedup();
|
||||
@@ -406,13 +407,13 @@ mod tests {
|
||||
fn descriptor_is_stable_and_distinguishes_inputs() {
|
||||
let a = ReelSelector::Memories {
|
||||
span: MemoriesSpan::Day,
|
||||
tz_offset_minutes: -480,
|
||||
display_tz: "America/New_York".to_string(),
|
||||
library: None,
|
||||
max_segments: 24,
|
||||
};
|
||||
let b = ReelSelector::Memories {
|
||||
span: MemoriesSpan::Week,
|
||||
tz_offset_minutes: -480,
|
||||
display_tz: "America/New_York".to_string(),
|
||||
library: None,
|
||||
max_segments: 24,
|
||||
};
|
||||
@@ -429,21 +430,33 @@ mod tests {
|
||||
created: Some(1_560_000_000), // 2019
|
||||
modified: None,
|
||||
library_id: 1,
|
||||
date_utc: None,
|
||||
camera_offset_minutes: None,
|
||||
date_taken_tz_source: None,
|
||||
},
|
||||
memories::MemoryItem {
|
||||
path: "b".into(),
|
||||
created: Some(1_560_086_400), // 2019
|
||||
modified: None,
|
||||
library_id: 1,
|
||||
date_utc: None,
|
||||
camera_offset_minutes: None,
|
||||
date_taken_tz_source: None,
|
||||
},
|
||||
memories::MemoryItem {
|
||||
path: "c".into(),
|
||||
created: Some(1_623_000_000), // 2021
|
||||
modified: None,
|
||||
library_id: 1,
|
||||
date_utc: None,
|
||||
camera_offset_minutes: None,
|
||||
date_taken_tz_source: None,
|
||||
},
|
||||
];
|
||||
assert_eq!(distinct_years(&items, None), vec![2019, 2021]);
|
||||
assert_eq!(
|
||||
distinct_years(&items, chrono_tz::Tz::America__New_York),
|
||||
vec![2019, 2021]
|
||||
);
|
||||
}
|
||||
|
||||
// Build an item at a given unix timestamp (seconds) with a chosen extension.
|
||||
@@ -453,6 +466,9 @@ mod tests {
|
||||
created: Some(ts),
|
||||
modified: None,
|
||||
library_id: 1,
|
||||
date_utc: None,
|
||||
camera_offset_minutes: None,
|
||||
date_taken_tz_source: None,
|
||||
}
|
||||
}
|
||||
fn item_at(ts: i64, name: &str) -> memories::MemoryItem {
|
||||
|
||||
@@ -323,6 +323,7 @@ impl Default for AppState {
|
||||
face_dao.clone(),
|
||||
knowledge_dao,
|
||||
persona_dao.clone(),
|
||||
user_ai_prefs_dao.clone(),
|
||||
libraries_vec.clone(),
|
||||
);
|
||||
|
||||
@@ -550,6 +551,7 @@ impl AppState {
|
||||
face_dao.clone(),
|
||||
knowledge_dao,
|
||||
persona_dao.clone(),
|
||||
Arc::new(Mutex::new(Box::new(SqliteUserAiPrefsDao::new()))),
|
||||
vec![test_lib],
|
||||
);
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ pub async fn unified_search<TagD: TagDao>(
|
||||
log::info!("unified_search: translating with model={}", backend.model());
|
||||
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
let sq = match translate_nl_query(backend.chat(), &nl, &tag_vocab, today).await {
|
||||
let sq = match translate_nl_query(backend.chat(), &nl, &tag_vocab, today, &ctx).await {
|
||||
Ok(sq) => sq,
|
||||
Err(e) => {
|
||||
log::warn!("unified_search: translate_nl_query failed: {e:?}");
|
||||
|
||||
Reference in new issue
Block a user