- Thread tools are bound server-side to the chat's own thread. The model
used to pass contact_id, so practice mode could read any conversation it
named; a model-supplied contact_id is now only checked, never trusted, and
the prompts no longer hand it one.
- Practice chats need no persona. They're stored under PRACTICE_PERSONA_ID
and run with an inert stand-in, so deleting or editing personas can't hide
or break them. persona_id is optional on create for as_contact.
- The per-thread list filters in SQL (list_persona_chats_for_contact, served
by idx_persona_chat_anchor_contact) instead of decoding every transcript.
- fetch_mms_image checks the content type and a 20 MB cap before reading
the body, so a video attachment isn't downloaded in full.
- get_photos_near_time unions tags over every path sharing the content, per
the multi-library model.
- look_at_photo only opens indexed photos, so an excluded folder can't be
read by guessing a path.
- One date parser: get_photos_near_time uses date_info::parse_center.
- Practice prompt source rewrapped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A local insight's transcript carries the photo itself, so continuing on hybrid
was refused. Each hybrid turn now has the local vision model describe the
photo (cached per image), sends the description in its place, and puts the
photo back before saving, so the stored chat and later local turns keep it.
If the local description fails, the turn fails. The stored model id is only
reused on the same backend, so a llama-swap slot isn't sent to OpenRouter.
Checked end to end against a recording fake OpenRouter on all three
continuation endpoints: no image data left, the stored transcript kept the
photo, and switching back to local saw the photo again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An anchored conversation now has a mode. 'about' is the existing behaviour (a
persona talks with the user about the thread). 'as_contact' has the model play
the contact so the user can rehearse the conversation: its own prompt (stay in
the contact's voice, don't invent facts, coach on 'out of character'), no
persona prompt, and only the thread tools — no memory, library, calendar or
other-thread access, and no knowledge writes.
The shared persona/file-chat loop now refuses tool calls that weren't in the
turn's catalog, so the gates hold even when a model names a tool it wasn't
offered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Long insight/persona chats on llama-swap failed with "exceeds the
available context size" even after raising Context in the app:
- llama-server fixes its window at launch (-c) and ignores num_ctx, but
the history budget trusted the requested num_ctx, so raising it kept
MORE history than the server could hold. LlmClient::context_window()
now reports the served window (llama-swap: /upstream/<model>/props,
cached 10 min) and the budget uses min(requested, served).
- apply_context_budget could only drop tool-call pairs, so plain Q&A
chats never shrank. It now also drops whole earlier exchanges (keeping
user/assistant alternation) and, last, the current turn's tool results,
never the in-progress question.
- The budget only ran once, before the tool loop. ContextFitter now
fits every LLM call (on a copy; the stored transcript is untouched)
and, on a context-overflow error, shrinks the budget from the server's
reported token counts and retries once.
GET /insights/models reports each llama-swap model's context_window when
it's known without loading the model (cached, or currently running), for
the app's Context picker.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
trace_db_call no longer takes an OpenTelemetry Context: it opens a
db.<query_type>.<operation> span (same name via otel.name, same
db.query_type / db.operation attrs) on the caller's ambient tracing span
and runs the DAO body inside it. The cx / ctx / context parameter is gone
from every DAO trait method, impl and call site, and from the helpers and
background drains that only threaded it.
The remaining request-handler spans (files, tags, faces, image, video,
favorites, duplicates, memories) move to #[instrument]; the
Context::current_with_span / with_remote_span_context plumbing is gone, as
is extract_context_from_request (the tracing-actix-web root span covers
it). otel::set_span_attributes replaces BoxedSpan::set_attributes.
Background-job raw spans (thumbnails, video actors) are left for #142.
tests/otel_db_spans.rs pins name, attributes, status and nesting (across
an instrumented spawn); otel_traceparent now uses an #[instrument]
handler. CLAUDE.md documents the tracing conventions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LlmClient methods no longer take an OpenTelemetry Context. llm.request is
now a tracing span (otel.kind=client, llm.provider/operation/model) that
parents on the caller's ambient span; traceparent injection reads the
span's OTel context via OpenTelemetrySpanExt.
The src/ai feature spans (handlers, insight generation, chat, persona
chat, daily summaries, TTS) move to #[instrument] / info_span!, and every
LLM-bearing tokio::spawn carries its parent span with .instrument(): the
streaming chat turn (chat_turn_stream_inner), async chat / persona turns,
background insight jobs and TTS jobs. Sub-spans in the agentic loops
(ai.chat.loop, ai.agentic.loop) instrument each LLM / tool await.
DAO calls keep their explicit Context (derived from the tracing span)
until the DAO migration (#141).
tests/otel_llm_spans.rs pins nesting + traceparent for the blocking and
streaming paths across a spawn on a multi-thread runtime.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The legacy SSE streaming path (chat_turn_stream -> run_streaming_turn ->
run_bootstrap/continuation_streaming) built its LLM spans from
Context::current(), which is always the empty root context here: the codebase
threads OTel contexts explicitly and never attaches one, there is no
context-aware tokio layer, and the turn runs on a tokio::spawn. So every
llm.request span on that path was an orphaned root span, uncorrelated with the
HTTP request.
Thread the handler's extracted request context down through
chat_turn_stream -> chat_turn_stream_inner -> run_streaming_turn ->
run_bootstrap_streaming / run_continuation_streaming, and use it for the
describe_image and agentic-loop calls. Mirrors the non-streaming path, which
already threads parent_cx -> loop_cx.
Also rename the block-scoped db 'cx' in run_streaming_turn to db_cx so it no
longer shadows the new parameter.
Add OpenTelemetry tracing to every LLM client call so an insight/chat
turn is a single trace from HTTP entry to each downstream inference.
- LlmClient trait: thread a &opentelemetry::Context through all 5
methods (generate, chat_with_tools, chat_with_tools_stream,
generate_embeddings, describe_image), matching the DAO convention of
explicit context threading rather than ambient Context::current().
- otel::start_llm_request_span: single shared helper that every
provider (openrouter / llamacpp / ollama) funnels through, so the
span shape is identical: name llm.request, SpanKind::Client,
llm.provider / llm.operation / llm.model.
- Per-method spans in all three clients with the spec's operation enum
(chat | chat_stream | embeddings | describe_image | generate); stream
spans are owned and moved into the async_stream generator so they end
when the response drains, not when headers arrive.
- llm.tokens.input / llm.tokens.output recorded on non-stream and
stream completion; llm.cost_usd from usage.cost (OpenRouter);
http.status_code + error status on failure.
- W3C traceparent injected on outgoing LLM HTTP requests (llama-swap,
Ollama, OpenRouter) gated on span_context.is_valid() so debug
no-op-tracer builds don't send an all-zero parent.
- Turn-level spans (ai.chat.turn.execute, ai.persona_chat.turn.execute)
now carry llm.backend + llm.model; with_entry streaming paths thread
the turn context (turn_cx) to child describe_image / agentic-loop
calls instead of the ambient context.
clippy clean; 690 lib + 775 integration + 4 doc tests green.
- New src/date_view.rs: pure read-side functions (row_true_instant,
row_display_wall, row_display_day, filename_true_instant) applying the
per-source convention table for unconverted rows and the new-contract
triple for converted rows (ADR-0001).
- /memories: grouping on the row's true instant rendered in the owner's
display zone (prefs -> app default), not the client device offset.
SQL is a coarse interval pre-filter over the full years-back window;
exact month-day/week/month matching across past years happens in Rust
(memories::day_in_span, sqlite_w_week).
- Reels: ReelSelector::Memories carries an IANA display_tz instead of a
fixed minute offset; pre-gen params read REEL_PREGEN_DISPLAY_TZ; the
tz_offset_minutes ledger column stores the zone offset at render time.
- Insights: prompt-facing dates render as the display wall; the
proximity timestamp and chat context block use the true instant;
InsightGenerator reads the owner's display zone from prefs per call.
- GpsPhotoSummary carries the new-contract triple alongside the frozen
legacy date_taken.
Closes#119
The filename date class is now first-class (ADR-0001):
- memories::extract_date_from_filename returns FilenameDate:
Wall(NaiveDateTime) for the screenshot/dash/compact patterns,
Utc(DateTime<Utc>) for the unix-timestamp patterns. No patterns
added or removed; every existing pattern carries its convention.
- Waterfall inversion in both resolve_date_taken and
resolve_dates_batch: an in-class name is the original date of
record and resolves BEFORE EXIF / container dates are consulted
(shared filename_class_resolved step; in-class files skip
exiftool entirely). Wall patterns run the offset cascade over the
kamadak prior's evidence (empty evidence -> server-zone
assumption, so a class file always resolves); timestamp patterns
are true instants (tz_source utc_direct, no offset).
- Legacy date_taken stays byte-identical: wall-as-UTC for wall
patterns, epoch for timestamp patterns, via
FilenameDate::legacy_datetime / legacy_timestamp at the seven
consumer call sites.
- Out-of-class files keep the global EXIF-wins waterfall unchanged.
- CLAUDE.md waterfall section rewritten (step 0 = filename class;
the EXIF-beats-filename note becomes out-of-class-only).
Tests: five new waterfall-seam tests (wall no-evidence ->
assumed_server; wall outranks EXIF with offset-tag evidence;
wall GPS evidence; timestamp utc_direct; in-class video name
resolves without exiftool) plus enum-shape assertions in every
extractor test. Code-reviewed on both axes (standards: no
violations, 3 of 4 P2 notes applied; spec: all acceptance
criteria verified, no scope creep).
716 integration + 639 lib tests pass; fmt + clippy clean.
The exiftool branch of the date waterfall now pulls raw strings with
group prefixes (one `exiftool -j -q -G1` subprocess per batch, no
`-d %s`) and classifies each candidate:
- zoned strings (own offset / Z) are true instants;
- naive strings from container groups (QuickTime/Track*/Apple) are
UTC-rendered creation_time fields -> true instants (utc_direct).
Running them through the camera wall-clock cascade was the
"videos off by 4-5 hours" bug: it reinterpreted a UTC instant in
the server zone;
- naive strings from photo-EXIF groups are camera wall clocks and run
the offset cascade (OffsetTime* pairing + GPS evidence pulled in
the same exiftool pass).
The legacy date_taken column stays byte-identical to the old
server-local interpretation (reproduced in Rust, DST-aware); the
new-contract date_utc / camera_offset_minutes / date_taken_tz_source
columns carry the true instant.
Manual date edit (POST /image/exif/date) reinterprets the
naive-as-UTC epoch the client sent as a wall clock in the user's
display zone (camera_tz::manual_date_resolution, tz_source =
'manual'), snapshots the pre-override new-contract triple next to the
existing legacy snapshot pair (3 new columns, migration
2026-09-25-000000), and revert restores both pairs.
ADR-0001 records the container-date finding; CLAUDE.md date-pipeline
section updated.
Populate the new-contract date columns at ingest while keeping the
legacy date_taken column byte-identical (ADR-0001):
- camera_tz module: offset cascade (offset tag -> GPS zone -> server
zone) as a pure, testable function; bundled offline TZF geometry for
GPS -> IANA resolution; DST-correct offsets for the capture date;
display-zone helpers (DEFAULT_DISPLAY_TZ, display_zone_for,
display_zone_offset_minutes) for the read-side cutover.
- EXIF branch: kamadak-exif now collects OffsetTime* tags paired with
the winning date tag; resolve_date_taken takes an ExifPrior and
emits (date_utc, camera_offset_minutes, date_taken_tz_source) per
branch: exif -> cascade, exiftool/fs_time -> utc_direct, filename
-> unclassified (pending #116).
- Schema: migration adds image_exif.date_utc /
camera_offset_minutes / date_taken_tz_source and
user_ai_prefs.display_tz (all nullable; down.sql drops them).
- DAO + ingest: backfill_date_taken persists the triple alongside the
legacy pair; watcher, upload, and set_image_gps call sites feed the
cascade.
- Response surface: ExifMetadata and ExifSummary carry the triple so
/image/metadata and /photos/exif expose it (null for unconverted
rows); reel requests mirror display_tz into prefs.
cargo build + clippy clean; 625 lib + 702 bin tests pass (the one
failing doctest in utils.rs is pre-existing at the base commit).
Persona chat stored a flat Vec<ChatMessage> keyed on (user_id, persona_id),
which meant one rolling transcript per persona and no way to revisit a turn.
This moves it onto the same ChatHistoryStore tree the file chat uses and
gives conversations their own identity.
Storage
- messages_json now holds a serialized ChatHistoryStore. Reads accept the
old flat array and upgrade it in place, so existing transcripts survive
without a data migration. The upgrade drops the v1 seed greeting, which
the flat renderer hid but the tree renderer would surface as a bubble the
user has never seen.
- New migration re-keys persona_chat_conversations on an opaque
conversation_id and adds title + created_at, so one persona can hold any
number of separate threads. Every DAO read and write is scoped by user_id
as well: a conversation id is a bearer token for someone's transcript and
must never grant access on its own.
- The per-conversation lock and in-flight turn slot key on conversation_id,
so two threads with the same persona can run turns concurrently.
Endpoints
- POST/DELETE /persona_chat/conversations — start and remove a thread,
replacing /persona_chat/reset.
- GET /persona_chat/conversations — the chat list, with snippet and counts
derived from each tree's active branch.
- POST /persona_chat/rewind, POST /persona_chat/switch-branch,
GET /persona_chat/branches — rewind and fork, mirroring the file chat.
Index 0 is rewindable here (it is the user's own first question, not a
synthetic prompt) and re-anchors on the seed node.
- history/turn/rewind/switch-branch/branches all key on conversation_id;
history gained branch_id and now returns real fork_info, active_leaf_id
and viewing_branch_id instead of placeholders.
The turn body no longer carries a persona at all — it is read from the
stored conversation, so a stale client cannot swap a thread's voice midway.
Titles
After the first turn persists, the conversation is named from its opening
exchange on the same backend the turn ran on. Small models wrap titles in
quotes, prefix them with "Title:" and append explanations, so sanitize_title
strips all of that and truncates on a character boundary. Any failure falls
back to the user's opening question. Generation runs after persistence: a
failed title must not cost the turn.
Fixes found along the way
- insight_chat: both file-chat turn paths captured path.len() before
apply_context_budget drained messages out of the middle, then sliced
messages[path_len..] for the new tree nodes. Once truncation fired that
dropped the user turn from the tree or panicked on an out-of-range start
index. Now read after the budget pass as history_len.
- Persona chat had no context budget at all and hardcoded truncated: false
in the done frame, so a rolling transcript grew unbounded.
- A cancelled turn persisted a half-finished transcript and pushed a second
terminal frame; it now returns early like the file chat.
- The seeded system prompt was frozen at conversation creation, so editing a
persona never reached a thread already in flight. Re-resolved per turn.
- turn_count was overwritten each write with the per-turn message delta;
it is now the cumulative assistant-turn count on the active branch.
- is_initial is always false: the file chat reserves it for its synthetic
"describe this photo" prompt, and marking a persona chat's first question
with it made the opening reply impossible to regenerate.
600 lib tests pass, clippy --all-targets clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- PersonaChatSession dispatches turns through the shared agent loop,
persisting transcripts keyed on (user_id, persona_id) in the new
persona_chat table (migration included).
- GET /persona_chat/history returns a rendered transcript (tool
invocations folded, is_initial flag) matching the file-chat shape.
- POST /persona_chat/turn returns 202 with a turn_id; SSE replay and
cancel reuse turn_replay_impl/cancel_turn_impl, extracted from the
actix-attributed handlers so both route families share the logic.
- POST /persona_chat/reset clears the persona transcript.
- Concurrent dispatches for the same (user, persona) are rejected with
409 via an in-flight gate (InFlightPersonaTurns) whose RAII guard
drops with the spawned turn task, freeing the slot on completion,
error, or abort.
Changed last_fork.clone() to last_fork.take() in render_tree_path
for both assistant and user branches. This prevents fork_info from
propagating to every downstream message, ensuring the chip appears
only at the actual divergence point in the conversation tree.
ForkInfo now carries the divergence node's id, and the branches endpoint
accepts node_id + viewing_branch_id to return only the position-ranked
sibling branches at that fork (snippets taken from after the divergence,
skipping tool scaffolding) instead of every leaf in the tree. Options in
the same subtree as the viewing leaf anchor to that leaf so clients can
reliably identify "the branch I'm on". Tree-wide listing is unchanged
when node_id is absent.
- llm_client: descendant_leaves + branch_options_at helpers; optional
position rank on BranchLeafInfo.
- insight_chat: ForkInfo.node_id set during render_tree_path; get_branches
takes the scoping params.
- handlers: dedicated ChatBranchesQuery; unknown node_id maps to 400.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Store chat history as a tree in training_messages instead of a flat
array. Rewind now sets active_leaf_id to the target node rather than
truncating, so discarded paths survive as alternate branches. Fork
indicators ("X/Y") mark divergence points and let the client load or
switch to alternate paths.
- llm_client: StoredChatNode/ChatHistoryStore with tree traversal
helpers (path_to_leaf, children_of, fork_at_node, leaves*, etc.) and
from_flat_array for backward-compatible reads of the old flat format.
- insight_chat: load_history/chat_turn/rewind_history operate on the
tree; new switch_branch and get_branches. render_tree_path walks every
node (including non-rendered tool-dispatch nodes) when computing fork
info, so a fork whose diverging child is a tool call still surfaces on
the following rendered message.
- handlers: GET /insights/chat/branches, POST /insights/chat/switch-branch,
and a branch_id param on the history endpoint.
Backward-compatible: flat arrays are converted to a tree lazily on read.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add retry_with_backoff utility (100ms base, ±25% jitter, 3 attempts)
and replace all 11 direct DAO calls in insight_chat.rs and
insight_generator.rs. The Mutex guard drops between retries so the
maintenance connection can release its SQLite write lock.
New optional SamplingOverride forwarded to llama-server as
chat_template_kwargs.enable_thinking (gates Qwen3-style reasoning
blocks). None leaves the template default; other backends ignore it.
Wired through the agentic-insight and chat-turn request bodies/handlers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The history-truncation budget assumed an 8192-token context whenever a
chat request omitted num_ctx, while the llama-swap chat slots serve
20k-131k. Replayed transcripts past ~6k tokens were silently gutted
every turn — losing conversation history and destroying llama.cpp
KV-cache prefix reuse (full SWA re-prefill per turn).
Default is now 32768 (real conversations top out around 16k), with
AGENTIC_CHAT_DEFAULT_NUM_CTX to override per deploy, floored at
headroom + 1024. Explicit per-request num_ctx still wins.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A request carrying persona_id but no system_prompt used to fall back to
the neutral default voice. Both agentic generation
(generate_agentic_insight_handler) and chat bootstrap now resolve the
persona's stored prompt from the persona store, with precedence:
explicit non-blank client system_prompt > persona store lookup >
existing default ("default" persona id behaves the same — used if the
store has a row, neutral default otherwise). Resolution happens at the
handler / bootstrap entry where the DAO is reachable; internals are
unchanged. resolve_bootstrap_system_prompt takes the resolved persona
prompt as a second argument, with precedence tests.
Also in insight_chat:
- Sync chat_turn no longer persists the synthetic "Please write your
final answer now without calling any more tools." user message pushed
on iteration exhaustion — extracted both streaming variants'
synthetic_idx pattern into push/remove_synthetic_final_prompt (the
remove is a defensive no-op on index drift) and applied it to all
three loops; round-trip test included.
- Strip leaked <think> blocks from the final content persisted as the
reply in chat_turn and both streaming AgenticLoopOutcomes (mid-stream
TextDeltas are untouched; the raw transcript keeps the block).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Expose GET /insights/history?path=... returning every generated version
of a photo's insight (current plus superseded), newest-first, backing the
mobile per-file insight history view.
- New get_insight_history_handler; reuses the existing get_insight_history
DAO method (removed its dead_code allow).
- impl From<PhotoInsight> for PhotoInsightResponse, collapsing the mapping
that was duplicated across the single-get and all-insights handlers.
- rate_insight_by_id DAO method + optional insight_id on RateInsightRequest
so previously generated versions can be approved/rejected (the path-based
rate only touches the current row).
- DAO tests for history ordering/scoping and id-targeted rating.
- cargo fmt normalized a multi-line assert in insight_chat.rs tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The truncation budget estimated message size by serializing the full
ChatMessage array, including the base64 image persisted in the first
user message. A 1024px JPEG is hundreds of KB of base64 characters —
8-19x the entire ~24KB text budget at the default num_ctx — and the
image lives in the protected prefix that's never dropped. The budget
check was therefore essentially always over, dropping all tool history
and firing the "trimmed context" banner on every turn for vision
backends that inline images.
estimate_bytes now strips image payloads before counting and charges a
flat IMAGE_TOKENS_EACH per image instead, so the budget reflects real
text token pressure. Adds a regression test covering a short
conversation with one large image.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the one-shot SSE chat stream with an async dispatch + reconnectable
replay flow so the mobile client survives backgrounding, network blips, and
OS-killed sockets without losing an in-flight agentic turn.
- TurnRegistry/TurnEntry: in-memory per-turn event buffer (cap 500, front
eviction) shared by the agentic loop (writer) and SSE replay readers.
ReplayOutcome + replay_from/next_batch distinguish Events/CaughtUp/Gone;
next_batch registers the Notify before reading state (no lost wakeup) and
drains every buffered event before signaling terminal, so the final
Done/Error is never dropped and the stream closes cleanly.
- Endpoints: POST /insights/chat/turn (202 + turn_id), GET
/insights/chat/turn/{id} (SSE replay, ?skip_before= resume, per-event seq,
410 on eviction), DELETE /insights/chat/turn/{id} (real task abort +
cooperative is_running() check at each loop boundary).
- Cancellation actually stops the task (AbortHandle stored on the entry) and
emits a Done{cancelled:true}; callers skip persistence on cancel.
- Background sweeper drops stale turns; interval clamped to <=300s.
- OpenTelemetry spans: ai.chat.turn.execute/replay/cancel.
- Legacy POST /insights/chat/stream path preserved unchanged.
Tests: registry coverage for terminal delivery (race guard), waiting, Gone,
abort, eviction; handler integration tests for 404/410, skip_before, seq
stamping, completed replay, and cancel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add prompt_eval_count and eval_count columns to photo_insights so
token usage from llama-swap/Ollama is stored and returned by the API
- Fix agentic generator return: was (prompt_eval_count, eval_count),
handler destructured first element as insight_id — now returns
(insight_id, prompt_eval_count, eval_count)
- Wire prompt_eval_count/eval_count from DB into PhotoInsightResponse
instead of hardcoded None
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Replace impl ToString with impl Display for InsightJobStatus and
InsightGenerationType
- Rename from_str → parse to avoid confusion with std::str::FromStr
- Collapse nested if statements (handlers, insight_chat, insight_generator,
image handlers)
- Use is_multiple_of() instead of manual modulo checks
- Suppress deprecated diesel::dsl::count_distinct (no drop-in replacement
available in current Diesel version)
- Scope MutexGuard in synthesize_merge to drop before await
- Allow dead_code on generate_no_think, enumerate_indexable_files,
total_deleted (intended for future use)
- Allow type_complexity on Diesel query result tuples
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fix query param mismatch: rename GenerationStatusQuery.file_path to
path so the client's app-resume buildQuery({ path: ... }) resolves
correctly instead of always getting 400
- Remove dead _lib_id bindings from both generate handlers
- Return 202 Accepted instead of 200 from generate endpoints
- Restore OpenTelemetry span instrumentation on generate handlers
- Remove stale UNIQUE constraint from initial migration (incompatible
with plain-INSERT DAO)
- Add tests for status guard: complete_job/fail_job are no-ops when
job is already cancelled, and cancel_job by id
- Persist generation params (num_ctx, temperature, top_p, top_k, min_p,
system_prompt, persona_id) on the photo_insights table for auditing
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add insight_generation_jobs table migration and DAO
- Implement job lifecycle: create_or_get_active, complete, fail, cancel
- Refactor POST /insights/generate and /agentic to async spawn with timeout
- Add GET /insights/generation/status endpoint with job_id and file_path lookup
- Use String for enum fields in Diesel models to avoid private Bound type
- Add from_str() helpers on InsightJobStatus and InsightGenerationType
- Fix update_training_messages to return Result<usize, DbError>
- 7/7 DAO unit tests passing
When backend=hybrid with LLM_BACKEND=llamacpp, the user-selected model
(an OpenRouter id like "google/gemini-3-flash-preview") was being applied
to the local LlamaCppClient's primary_model and vision_model. This caused
describe_image to send the OpenRouter model name to llama-swap, which
returned 400 because it has no such slot.
Guard the local-client model override with !is_hybrid so it only applies
in local-only mode (where the user is selecting a different local model).
Bump to v1.2.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- sms search tool: accept contact name, trim/validate, skip when
contact_id is set, pass to API client
- sms_client: new contact field in SmsSearchParams, URL-encode on wire
- Tool description clarifies contact_id takes precedence when both given
- Add parse_title_body helper for LLM response parsing
- llamacpp backend improvements
Replace 5 copies of the ~80-line backend resolution pattern with a
single InsightGenerator::resolve_backend() builder that returns a
ResolvedBackend (chat + local clients, BackendKind enum, images_inline
flag). Tool dispatch now takes &ResolvedBackend instead of
&OllamaClient + model + backend strings.
Remove duplicated ollama/openrouter/llamacpp fields from
InsightChatService — InsightGenerator owns them and resolve_backend
uses them. Delete build_chat_clients (replaced by resolve_backend).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
llamacpp models now receive images via OpenAI content-parts instead of
the describe-then-inline strategy (hybrid mode unchanged). Fixes
assistant messages with tool_calls emitting content: null instead of ""
to satisfy strict Jinja template role-alternation checks. Adds debug
logging of message role sequences on llamacpp requests.
Introduces BackendKind enum, SamplingOverrides, and ResolvedBackend in
a new backend.rs module. InsightGenerator::resolve_backend centralises
client construction + vision capability detection — next step wires the
existing inline dispatch through it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reverts the per-request backend="llamacpp" value. Chat/vision/embedding
backend is now a deploy-time decision (LLM_BACKEND=ollama|llamacpp),
applied globally across chat, vision describe, and embeddings — so
embedding vectors stay in one space across the index.
- Per-request backend whitelist back to "local"|"hybrid". A request
arriving with backend="llamacpp" is rejected.
- LLM_BACKEND=llamacpp swaps the entire local stack to llama-swap:
chat hits the chat slot, describe hits the vision slot, embeddings
hit the embed slot. Hybrid mode still routes chat to OpenRouter
but uses LLM_BACKEND for the describe pass.
- Drops env vars HYBRID_VISION_BACKEND, LLAMA_SWAP_VISION_MODELS,
EMBEDDING_BACKEND (the last never shipped). Drops the
LlamaCppClient.vision_models allowlist — capability inference now
reports has_vision only for the configured vision_model slot.
- Drops the /insights/llamacpp/models handler. /insights/models is
the single endpoint; returns Ollama servers under LLM_BACKEND=ollama
and llama-swap slots (from LLAMA_SWAP_ALLOWED_MODELS) under
LLM_BACKEND=llamacpp. Same envelope shape either way.
- New ai::embed_one helper routes embeddings through llama-swap when
LLM_BACKEND=llamacpp (else Ollama). Wires it into the four
insight_generator embedding sites.
- Cross-replay matrix simplifies to pre-llamacpp shape (local↔local,
hybrid↔hybrid, hybrid→local allowed; local→hybrid rejected).
Wires a new LlamaCppClient (OpenAI-compatible /v1 wire format) alongside
OllamaClient and OpenRouterClient. Per-slot routing for chat/vision/embed
via env (LLAMA_SWAP_URL + *_MODEL vars); capability inference uses an
env allowlist since /v1/models doesn't report modality.
InsightGenerator + InsightChatService gain three-way dispatch on
chat_backend = "local" | "hybrid" | "llamacpp". Hybrid and llamacpp
share the describe-then-inline path (text-only chat after a separate
vision describe). HYBRID_VISION_BACKEND=llamacpp lets hybrid route its
describe pass through llama-swap's vision slot while chat still goes
to OpenRouter.
Cross-replay matrix added (validate_cross_replay): local<->llamacpp
and hybrid<->llamacpp allowed; local->hybrid and llamacpp->hybrid
rejected. New /insights/llamacpp/models handler mirrors the OpenRouter
shape.
Probe-phase scaffolding for CLIP semantic search. Adds the column
that will hold per-photo embeddings, the HTTP client to Apollo's
inference service, and a throwaway probe binary so we can eyeball
search-result quality on the live library before building the
persistence layer (backlog drain, /photos/search endpoint, UI).
- migrations/2026-05-14-000000_add_clip_embedding/ — adds
image_exif.clip_embedding (BLOB) and clip_model_version (TEXT),
plus a partial index on (clip_embedding IS NULL AND content_hash
IS NOT NULL) for the future backfill drain.
- src/database/models.rs — extends ImageExif struct to match.
- src/ai/clip_client.rs — encode_image / encode_text / health,
same Permanent/Transient/Disabled taxonomy as face_client.
- src/bin/probe_clip_search.rs — --query <q> --library N --limit M
--top K. Encodes a sample and prints top-K cosine similarities.
No DB writes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundles three coupled changes so agent-side mutations stay
auditable and reversible:
1. Audit columns on entity_facts —
`last_modified_by_model` / `last_modified_by_backend` /
`last_modified_at`. Stamped on every mutation path
(update_fact, supersede_fact, manual PATCH, manual supersede,
the new revert). NULL on rows never touched since creation.
Partial index on `last_modified_at WHERE NOT NULL` keeps the
"show me recent edits" feed fast without bloating from legacy
rows.
2. Per-persona gate `personas.allow_agent_corrections` (BOOLEAN,
default 0). Defense in depth at two layers:
- build_tool_definitions: when off, `update_fact` and
`supersede_fact` aren't in the catalog at all, so even a
hallucinated tool call by the model fails fast.
- tool_update_fact / tool_supersede_fact: re-checks the persona
flag at call time and returns an explicit "corrections
disabled" error if it's somehow off (e.g. flag flipped mid-
loop).
ToolGateOpts grows the flag; current_gate_opts splits into
`current_gate_opts` (no persona context, defaults closed) +
`current_gate_opts_for_persona` for chat callers that have a
persona id. Both call sites in insight_chat are updated.
3. Revert action — new DAO method `revert_supersession` +
`POST /knowledge/facts/{id}/restore`. Flips status back to
'active', clears `superseded_by`, clears `valid_until` (we
don't track whether it was hand-set vs auto-stamped, so the
safe reset is to drop it — user can re-bound after). Stamps
`last_modified_*` so the revert itself is attributable.
Manual paths (PATCH / supersede via HTTP, plus restore) stamp the
audit columns with `("manual", "manual")`. Agent paths stamp the
loop-time chat model and backend (mirroring the existing
created_by_* convention).
FactDetail in the HTTP response now carries the audit triple
alongside the existing provenance. Apollo wires the new field set
in the matching commit.
PersonaView / UpdatePersonaRequest grow `allowAgentCorrections`;
the PersonaPatch + InsertPersona + bulk_import paths thread it.
317 lib tests pass, including unchanged update_fact / supersede
DAO tests (now passing audit=None — None means "no provenance
context to attribute", legacy semantics).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two nullable TEXT columns to entity_facts —
`created_by_model` (LLM identifier) and `created_by_backend`
("local" / "hybrid" / "manual" / NULL) — so the curator can audit
which configurations produce good fact-keeping and which produce
noise.
photo_insights already carries model_version + backend, and
entity_facts.source_insight_id links to it, but:
- source_insight_id is set post-loop, so chat-continuation and
regenerated-insight facts lose the link.
- JOINing per read is more friction than embedding provenance on
the row itself.
- Manual facts (POST /knowledge/facts) have no insight at all and
need their own "manual" provenance marker.
Threading: execute_tool grows `model` + `backend` params, passed
from the three call sites (agentic insight loop, chat single-turn,
chat stream) using the loop-time `chat_backend.primary_model()` +
`effective_backend` already in scope. tool_store_fact stamps the
new fact accordingly; manual create_fact stamps backend="manual".
Legacy rows leave both NULL — pre-tracking data can't be back-
filled reliably from training_messages without burning compute.
Indexes are partial (WHERE NOT NULL) so legacy rows don't bloat
them, and "show me all facts from model X" stays fast.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a photo exists in more than one library and the user
regenerates its insight from library A's chat, the regenerate
streams cleanly, store_insight flips library A's old row to
is_current=false, and inserts a new is_current=true row tagged
(library A, rel_path). On the next history fetch the user sees
their old transcript — the regenerate appears to vanish.
The cause: get_insight(file_path) filters on rel_path + is_current
only, so library B's untouched is_current=true row for the same
rel_path satisfies the query and gets returned by SQLite's .first()
ahead of A's new row. Because get_insight is also what
chat_turn_stream uses to decide bootstrap vs. continuation, the
next chat turn after the shadow hit also routes against the
wrong insight, so update_training_messages corrupts library B's
transcript with library A's chat.
Fix: add get_current_insight_for_library(library_id, file_path)
filtered on (library_id, rel_path, is_current=true) and route the
chat surface (load_history, chat_turn{,_stream}, rewind_history)
through it. load_history falls back to the cross-library
get_insight when the scoped lookup misses — preserves the
"scalar data merges across libraries" intent for the case where
the active library has no insight but another does. The path-only
get_insight stays for callers that don't have library context
(populate_knowledge, the photo-grid metadata fetch).
chat_history_handler stops dropping the parsed library on the
floor and threads it through. Single-library deploys see no
behaviour change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two persona-infrastructure correctness fixes that go together because
the second one (FK with CASCADE) requires the first (preventing the
persona row from being mutated out from under its facts).
1. update_persona handler refuses name/systemPrompt edits to built-ins
(409). includeAllMemories stays editable — that's a per-user
preference, not the persona's identity. Mirrors the existing
delete_persona guard. The DAO is intentionally permissive so the
guard sits at the HTTP layer; persona_dao test pins that contract.
2. Migration 2026-05-10 adds user_id to entity_facts and a composite
FK (user_id, persona_id) -> personas(user_id, persona_id) ON DELETE
CASCADE. This closes two issues at once:
- Persona orphans: deleting a custom persona used to leave its
facts dangling forever, readable only via PersonaFilter::All.
CASCADE now wipes them with the persona row.
- Multi-user fact leakage: PersonaFilter::Single("default") used
to surface every user's default-scoped facts. PersonaFilter is
now { user_id, persona_id } and all read paths
(get_facts_for_entity, list_facts, get_recent_activity) filter
on user_id first. upsert_fact's dedup key extends to user_id so
identical claims under shared persona names from different
users no longer corroborate-bump each other's confidence.
- user_id threads from Claims.sub.parse::<i32>().unwrap_or(1) at
the chat / insight handlers through ChatTurnRequest, the
streaming agentic loop, execute_tool, and into the leaf tools
(tool_store_fact, tool_recall_facts_for_photo). The ".unwrap_or(1)"
accommodates Apollo's service token whose sub is non-numeric on
legacy mints.
- Backfill picks the smallest user_id matching each legacy fact's
persona_id so the FK holds for already-stored rows.
Five new knowledge_dao tests with FK-on connection: persona scoping
isolation, All-variant union per-user, dedup not crossing users,
CASCADE delete, FK rejection of unknown personas. Plus
dao_update_does_not_block_built_ins documenting where the
HTTP-layer guard lives.
Apollo coordinates separately — the matching changes there add the
/api/personas proxy and start sending persona_id on photo-chat turns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move personas off the mobile client into ImageApi as first-class
records, and scope entity_facts by persona so each one builds its own
voice over a shared entity graph. The new include_all_memories flag
lets a persona opt back into the full hive-mind pool for human
browsing of /knowledge/*; agentic generation always stays in-voice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bootstrap system message gave the model a file path and (in
hybrid mode) a visual description, but no temporal anchor. Models
defaulted to today's date when calling get_sms_messages — Nov 2014
photos were getting "2024-03-11" passed as `date`, missing every
historical message and leading the model to confidently misreport
context.
This commit folds two more EXIF-sourced facts into the
--- PHOTO CONTEXT --- block:
Date taken: <YYYY-MM-DD or "unknown">
GPS: <lat, lon to 4dp> (omitted when no GPS)
Resolution waterfall for date_taken matches the documented canonical
date pipeline at the EXIF / filename steps, but intentionally stops
short of the fs-time fallback `generate_agentic_insight_for_photo`
uses — for chat we'd rather show "unknown" than mislead the model
with an inode mtime. GPS is taken straight from EXIF when both
lat/lon are populated; absent GPS suppresses the line entirely so
the model doesn't hallucinate coordinates.
InsightGenerator gains a `fetch_exif(file_path)` accessor (crate-
visible) so the chat service doesn't need its own ExifDao plumbing.
build_bootstrap_system_message picks up two new params (date,
gps); existing tests updated and 5 new tests cover:
- date present / absent / waterfall (EXIF wins, filename fallback,
None when neither source has it)
- GPS present / absent
- ordering (path → date → visual)
Total insight_chat unit tests: 33 (up from 27).
After refresh, the rendered transcript was showing two unwanted
artifacts in the initial user bubble:
Photo file path: pics/DSC_5171.jpg
please tell me about this photo and what was going on around it
Please write your final answer now without calling any more tools.
Two distinct bugs:
1. Bootstrap was prepending `Photo file path: <path>` (and, in
hybrid mode, the visual description block) into the user-turn
content. The model needed it to call file_path-keyed tools, but
the user could see it in their own bubble on replay.
2. The no-tools fallback ("Please write your final answer now…")
was a synthetic user message we never stripped from history,
so it persisted into training_messages, rendered as a second
user bubble, AND wiped the prior tool-call accumulator inside
load_history (user-turn handler clears pending_tools), which
is why the tool invocations disappeared from the assistant
bubble after refresh.
Fixes:
- New `build_bootstrap_system_message` helper composes the persona
with a `--- PHOTO CONTEXT ---` block (path + optional visual
description). Lives in the system message, not the user turn.
The user's bubble shows only what they typed.
- Streaming agentic loop's no-tools fallback now records its
insertion index and removes the synthetic user prompt from
`messages` after the model responds. Final assistant content
stays — it reads coherently on replay without the synthetic
prompt above it. Applies to both bootstrap and continuation.
3 new tests cover the system-message composer (path-only, with
visual block, persona-trim). Total insight_chat unit tests: 27.
Bug: bootstrap user_content was just the user's typed message (plus
the hybrid visual description). Tools that take a file_path arg —
recall_facts_for_photo, get_file_tags, get_faces_in_photo — had no
way to learn the canonical path. Small models would invent
placeholders like "input_file_0.png" or call the tool with a name
guessed from a hidden multimodal input handle, neither of which
matched any real photo.
Fix: prepend a single-line "Photo file path: <normalized>\n\n" block
to user_content. Same shape generate_agentic_insight_for_photo
already uses for non-chat callers — kept the bootstrap minimal
(no date / GPS / tags pre-stuffing; the agentic loop can fetch
those via tools when needed).
Hybrid still injects the visual description block between the path
block and the user message; local mode just gets path + user text.
resolve_bootstrap_system_prompt and resolve_bootstrap_backend run on
every bootstrap turn — they pick the persisted system prompt and the
chosen backend label. They were inline conditionals before; pulling
them out makes the rules testable without spinning up the full
streaming stack.
9 new tests cover:
- system prompt fallback to BOOTSTRAP_DEFAULT_SYSTEM_PROMPT for None,
empty string, whitespace-only
- supplied non-empty prompts pass through verbatim, with interior
newlines / spacing preserved (Apollo personas use multi-line tool
listings)
- backend defaults to "local" for None / empty
- "local" / "hybrid" accepted case-insensitively with edge-trim
- unknown labels return a descriptive error
Total insight_chat tests: 24 (up from 15). No behaviour change.
Tap-Discuss-on-no-insight previously failed silently: ImageApi's
/insights/chat/stream required an existing agentic insight, errored
when missing, and emitted the failure as `event: error` — which the
frontend SSE consumer ignored (it listens for `error_message`).
This commit closes both gaps with a server-side state machine:
- /insights/chat/stream now branches on insight presence. Missing
insight (or `regenerate: true` in the body) → bootstrap path:
builds [System(req.system_prompt), User(req.user_message + image)],
runs the agentic loop, generates a title, persists a new row via
store_insight (which auto-flips priors). Existing insight →
continuation path (unchanged behaviour).
- New `regenerate: bool` request field forces bootstrap even when an
insight exists. Takes precedence over `amend`.
- `done` SSE payload field-name alignment with Apollo's frontend
convention: prompt_eval_count → prompt_tokens, eval_count →
eval_tokens, num_ctx echo added.
- `amended_insight_id` semantics broaden — now populated whenever the
turn produced a new row (bootstrap, regenerate, or amend). Existing
amend clients keep working unchanged; new clients get the new row's
id for free.
- `event: error` → `event: error_message` so frontend errors stop
silently dropping.
Refactor: extracted run_streaming_agentic_loop, build_chat_clients,
and generate_title as shared helpers between bootstrap and
continuation. Continuation path's outer logic moves to
run_continuation_streaming with no behaviour change.
Mobile-ready: any client (Apollo backend, mobile, future) sends one
request to /insights/chat/stream and gets the right path. Apollo's
proxy stays a dumb pipe.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
No behavior change — purely whitespace/line-break cleanup that had
accumulated since the last format run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tools whose backing tables are empty (calendar, location_history,
daily_summaries) drop out of the catalog so the LLM doesn't waste
iteration budget calling them only to receive "no results found".
Vision and apollo gates already existed; this generalizes the pattern.
search_messages gains start_ts/end_ts/contact_id filters (date filter
is a client-side post-filter; SMS-API only accepts contact_id natively
on the search endpoint).
Descriptions follow a consistent convention: one sentence (what +
when), param semantics, examples for tools with non-obvious param
choices. No more all-caps headers, no more identity-prescriptive
language inside descriptions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Trim the override input once via Option::map(str::trim).filter(...).
- Use matches!() in restore_system_prompt_override's Prepended arm so
it reads consistently with the Replaced arm.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>