Merge pull request 'feature/insight-chat-improvements' (#83) from feature/insight-chat-improvements into master
Reviewed-on: #83
This commit was merged in pull request #83.
This commit is contained in:
@@ -658,7 +658,12 @@ clients whether chat is available for a given insight.
|
||||
|
||||
- `POST /insights/chat` runs one turn of the agentic loop against the replayed
|
||||
history. Body: `{ file_path, library?, user_message, model?, backend?, num_ctx?,
|
||||
temperature?, top_p?, top_k?, min_p?, max_iterations?, amend? }`.
|
||||
temperature?, top_p?, top_k?, min_p?, max_iterations?, system_prompt?, amend? }`.
|
||||
`system_prompt` is a per-turn override: in append mode (default) it's applied
|
||||
ephemerally — the original system message is restored before persistence so
|
||||
the stored transcript keeps its baked persona. In amend mode the override
|
||||
stays in place and becomes the new insight row's system message. Mirrors the
|
||||
internal `annotate_system_with_budget` swap-and-restore pattern.
|
||||
- `POST /insights/chat/stream` is the SSE variant — same request body, response
|
||||
is `text/event-stream` with events: `iteration_start`, `text` (delta), `tool_call`,
|
||||
`tool_result`, `truncated`, `done`, plus a server-emitted `error_message` on
|
||||
|
||||
392
specs/002-insight-chat-improvements.md
Normal file
392
specs/002-insight-chat-improvements.md
Normal file
@@ -0,0 +1,392 @@
|
||||
# Insight Chat improvements — design
|
||||
|
||||
**Date:** 2026-05-07
|
||||
**Branch:** `feature/insight-chat-improvements` (in both `ImageApi/` and `FileViewer-React/`)
|
||||
**Scope:** ImageApi photo-anchored insight + chat surface, plus the
|
||||
FileViewer-React client. Apollo's free/visit chat is **not** in this cycle.
|
||||
|
||||
## Problem
|
||||
|
||||
Three concrete gaps in today's insight + chat surface:
|
||||
|
||||
1. **Tool drift.** ImageApi exposes 13 tools to the LLM. Some are gated on
|
||||
`apollo_enabled` / `has_vision`, but several optional ones
|
||||
(`search_rag`, `get_calendar_events`, `get_location_history`) are
|
||||
registered unconditionally even when their backing tables are empty.
|
||||
Descriptions vary in quality and a couple have outright bugs.
|
||||
2. **Inconsistent / incomplete tool descriptions.** Tools like
|
||||
`search_messages` describe their selection rules but omit useful
|
||||
examples; `store_fact` doesn't show the `object_entity_id` vs
|
||||
`object_value` choice; `get_sms_messages` accepts a `days_radius`
|
||||
parameter that the backing client silently ignores. The LLM is being
|
||||
instructed against a slightly wrong reality.
|
||||
3. **System prompt fights the persona.** Today's generation prompt
|
||||
prepends the user's `custom_system_prompt` and then immediately asserts
|
||||
`"You are a personal photo memory assistant..."`. The user message
|
||||
demands `"a detailed insight with a title and summary"`. Both
|
||||
contradict whatever voice / shape / POV the persona just established.
|
||||
On chat continuation the persona is baked into the stored transcript at
|
||||
generation time and can't be changed without regenerating.
|
||||
|
||||
## Goals
|
||||
|
||||
- Tool catalog is **representative** — every tool registered for a turn is
|
||||
backed by data the user actually has.
|
||||
- Tool descriptions are **concise but complete**, with examples for any
|
||||
tool whose param choice has multiple modes or non-obvious interactions.
|
||||
- Persona / system prompt is **authoritative** for voice, length, and
|
||||
shape — both at generation and during chat continuation.
|
||||
- Per-turn system prompt overrides on chat work without surprising
|
||||
side-effects on the stored transcript outside `amend` mode.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Apollo backend / frontend changes. Separate cycle.
|
||||
- Refactoring the `generate_photo_title` post-hoc title flow. Already
|
||||
takes `custom_system_prompt`.
|
||||
- Tool consolidation (e.g. merging `search_messages` + `get_sms_messages`).
|
||||
Considered and deferred — keeps blast radius small.
|
||||
- Removing knowledge-memory tools (`recall_*` / `store_*`). Audit
|
||||
confirmed they have a live read path via `knowledge.rs` HTTP routes.
|
||||
- Persisting persona changes to the stored transcript outside `amend`
|
||||
mode. Deliberate — re-opens use the persona currently active in the
|
||||
client, not a sticky historical setting.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### A. System prompt — generation
|
||||
|
||||
Today (`insight_generator.rs:3305–3326`):
|
||||
|
||||
```
|
||||
[custom_system_prompt if any] +
|
||||
"You are a personal photo memory assistant helping to reconstruct..." +
|
||||
{owner_id_note} +
|
||||
{fewshot_block} +
|
||||
"IMPORTANT INSTRUCTIONS:
|
||||
1. You MUST call multiple tools...
|
||||
2. When calling get_sms_messages and search_rag...
|
||||
3. Use recall_facts_for_photo...
|
||||
...
|
||||
8. You have a hard budget of {max_iterations} iterations..."
|
||||
```
|
||||
|
||||
The first concatenation is the bug: `custom` claims one identity, the
|
||||
next line asserts another.
|
||||
|
||||
**New structure** — two named blocks, in order:
|
||||
|
||||
```
|
||||
[Identity / voice / format block] ← persona-controlled (or neutral default)
|
||||
[Procedural block] ← always identity-free
|
||||
```
|
||||
|
||||
**Identity block:**
|
||||
- When `custom_system_prompt` is supplied: use that string verbatim, no
|
||||
pre/append.
|
||||
- When not: a neutral default that doesn't fight a future persona.
|
||||
Working text: `"You are reconstructing a memory from a photo. Use the
|
||||
gathered context to write a thoughtful summary; you decide voice,
|
||||
length, and shape."`
|
||||
|
||||
**Procedural block** — identity-free, always emitted:
|
||||
|
||||
```
|
||||
Tool-use guidance:
|
||||
- You have a budget of {max_iterations} tool-calling iterations.
|
||||
- Call tools to gather context BEFORE writing your final answer; don't
|
||||
answer after one or two calls.
|
||||
- When calling get_sms_messages or search_rag, make at least one call
|
||||
WITHOUT a contact filter — surrounding events matter even when a
|
||||
contact is known.
|
||||
- Use recall_facts_for_photo + recall_entities to load any prior
|
||||
knowledge about subjects in the photo.
|
||||
- When you identify people / places / events / things, use store_entity
|
||||
+ store_fact to grow the persistent memory.
|
||||
- A tool returning no results is informative; continue with the others.
|
||||
|
||||
{owner_id_note if applicable}
|
||||
{fewshot_block if applicable}
|
||||
```
|
||||
|
||||
Differences from today's "IMPORTANT INSTRUCTIONS" block: removed the
|
||||
"you are a personal photo memory assistant" framing and the explicit
|
||||
"at least 5 tool calls" floor (replaced with the softer "don't answer
|
||||
after one or two"). Few-shot stays — it's pattern-of-tool-use, not
|
||||
identity.
|
||||
|
||||
### B. User message — generation
|
||||
|
||||
Today (line 3357):
|
||||
|
||||
```
|
||||
{visual_block}Please analyze this photo and gather any relevant context
|
||||
from the surrounding weeks.
|
||||
|
||||
Photo file path: {file_path}
|
||||
Date taken: {date}
|
||||
{contact_info}
|
||||
{gps_info}
|
||||
{tags_info}
|
||||
|
||||
Use the available tools to gather more context about this moment
|
||||
(messages, calendar events, location history, etc.), then write a
|
||||
detailed insight with a title and summary.
|
||||
```
|
||||
|
||||
Problems: the trailing line bakes in output shape ("title and
|
||||
summary"), and the title from the resulting response is **discarded
|
||||
anyway** — `generate_photo_title` (line 3494) regenerates the title
|
||||
post-hoc from the summary. So the prompt is constraining voice for no
|
||||
data-model benefit.
|
||||
|
||||
**New payload** — context-only, no output prescription:
|
||||
|
||||
```
|
||||
{visual_block}Photo file path: {file_path}
|
||||
Date taken: {date}
|
||||
{contact_info}
|
||||
{gps_info}
|
||||
{tags_info}
|
||||
|
||||
Gather context with the available tools, then respond.
|
||||
```
|
||||
|
||||
The persona owns shape. If a user wants "title-then-paragraph" output,
|
||||
their persona prompt says so.
|
||||
|
||||
### C. System prompt — chat continuation
|
||||
|
||||
Add `system_prompt: Option<String>` to `ChatTurnRequest` (and to its
|
||||
HTTP wrapper `ChatTurnHttpRequest`). It carries through both the
|
||||
non-streaming `chat_turn` and the streaming `chat_turn_stream`.
|
||||
|
||||
**Append mode (default, `amend=false`)** — ephemeral
|
||||
swap-and-restore, mirroring the existing `annotate_system_with_budget`
|
||||
pattern:
|
||||
|
||||
1. Load stored transcript.
|
||||
2. If `system_prompt` is `Some(s)`:
|
||||
- If first message is a `system` role: stash original content,
|
||||
replace with `s`.
|
||||
- Else: prepend a synthetic ephemeral system message with `s` (note
|
||||
it's synthetic so the restore step pops it rather than rewriting).
|
||||
3. Run `annotate_system_with_budget` on top (existing per-turn budget
|
||||
note appends to whatever's there now).
|
||||
4. Run the agentic loop.
|
||||
5. **Before persistence**, restore the original system content (or pop
|
||||
the synthetic one). Run `restore_system_content` for the budget
|
||||
annotation as today.
|
||||
6. Save.
|
||||
|
||||
Result: the model sees the override; the stored transcript is
|
||||
unchanged outside the model's actual reply.
|
||||
|
||||
**Amend mode (`amend=true`)**:
|
||||
|
||||
- If `system_prompt` is supplied: the override stays in place during
|
||||
the serialization for the new insight row. The new row's
|
||||
`training_messages` system message is the override. `is_current=false`
|
||||
flips on prior rows as today.
|
||||
- If not supplied: behaves as today (stored transcript's system message
|
||||
carries forward unchanged).
|
||||
|
||||
### D. FileViewer-React — client wiring
|
||||
|
||||
`hooks/useInsightChat.tsx`:
|
||||
- `SendTurnOptions` gains `systemPromptOverride?: string | null`.
|
||||
- Inside `sendTurn`, before issuing the streaming POST:
|
||||
1. Read the active persona's `systemPrompt` from AsyncStorage
|
||||
(already loaded for generation flows — reuse the same accessor).
|
||||
2. If a one-shot `systemPromptOverride` is set, append as a suffix
|
||||
(`${persona}\n\n${override}`) so persona voice survives + override
|
||||
tweaks the turn.
|
||||
3. Include the resulting string as `system_prompt` on the request body.
|
||||
- No history-load change. The history endpoint still returns the stored
|
||||
transcript.
|
||||
|
||||
`components/InsightChatModal.tsx`:
|
||||
- Add a small "Style note" composer affordance — a one-shot text input
|
||||
that, when filled, becomes the `systemPromptOverride` for the next
|
||||
send. Cleared after send.
|
||||
- The existing persona chip continues to open `PersonaManagerModal`.
|
||||
|
||||
`hooks/usePersonas.tsx` and the bundled defaults:
|
||||
- Built-in `assistant` and `journal` prompts get audited and rewritten
|
||||
to **explicitly state voice / shape / length** — since the framework
|
||||
no longer guarantees a default shape, the persona must.
|
||||
|
||||
### E. Tool catalog — gating
|
||||
|
||||
Widen `build_tool_definitions` from `(has_vision: bool, apollo_enabled:
|
||||
bool)` to a single `ToolGateOpts` struct:
|
||||
|
||||
```rust
|
||||
pub struct ToolGateOpts {
|
||||
pub has_vision: bool,
|
||||
pub apollo_enabled: bool,
|
||||
pub daily_summaries_present: bool,
|
||||
pub calendar_present: bool,
|
||||
pub location_history_present: bool,
|
||||
}
|
||||
```
|
||||
|
||||
The chat / generation services compute the three new fields lazily per
|
||||
turn via `SELECT 1 FROM <table> LIMIT 1` (cheap; cached for the turn's
|
||||
duration). Lazy because operators import data after launch and we don't
|
||||
want to require a restart for the LLM to discover its new capabilities.
|
||||
|
||||
Per-tool gating:
|
||||
|
||||
| Tool | Existing gate | New gate |
|
||||
|---|---|---|
|
||||
| `describe_photo` | `has_vision` | unchanged |
|
||||
| `get_personal_place_at` | `apollo_enabled` | unchanged |
|
||||
| `get_calendar_events` | none | `calendar_present` |
|
||||
| `get_location_history` | none | `location_history_present` |
|
||||
| `search_rag` | none | `daily_summaries_present` |
|
||||
|
||||
All other tools always-on. (`get_sms_messages` and `search_messages`
|
||||
fail informatively if SMS-API is unreachable; not worth a startup probe
|
||||
since intermittent failures are the same shape.)
|
||||
|
||||
### F. Tool descriptions — convention
|
||||
|
||||
Every description follows:
|
||||
|
||||
1. One sentence: **what** + **when to call**.
|
||||
2. Param semantics worth knowing (units, ranges, mode behavior,
|
||||
precedence).
|
||||
3. **Example invocation** for tools with multiple modes, optional bands,
|
||||
or non-obvious parameter interactions.
|
||||
4. Cross-references when relevant: `prefer X when both apply`.
|
||||
|
||||
Banned: all-caps section headers inside descriptions
|
||||
(`"CONTENT search"`, `"TIME-BASED fetch"`); persona-prescriptive language
|
||||
(`"you are a..."`); behavioral references to other tools by description
|
||||
rather than name.
|
||||
|
||||
Tools getting examples: `search_messages`, `search_rag`, `store_fact`,
|
||||
`get_sms_messages`. Trivial tools (`get_current_datetime`,
|
||||
`reverse_geocode`, `get_file_tags`) skip the example.
|
||||
|
||||
Sample (`search_messages`):
|
||||
|
||||
> Search SMS/MMS message bodies. Modes: `fts5` (keyword + phrase + prefix
|
||||
> + AND/OR/NOT + NEAR proximity), `semantic` (embedding similarity,
|
||||
> requires generated embeddings), `hybrid` (RRF merge, recommended;
|
||||
> degrades to `fts5` when embeddings absent). Optional `start_ts` /
|
||||
> `end_ts` (real-UTC unix seconds) and `contact_id` filters. For pure
|
||||
> date / contact browsing without keywords, prefer `get_sms_messages`.
|
||||
>
|
||||
> Examples:
|
||||
> - `{query: "trader joe's"}` — phrase across all time.
|
||||
> - `{query: "dinner", contact_id: 42, start_ts: 1700000000, end_ts: 1700604800}`
|
||||
> — keyword within a contact and a week.
|
||||
> - `{query: "NEAR(meeting work, 5)"}` — proximity search.
|
||||
|
||||
### G. SMS tool fixes
|
||||
|
||||
#### `get_sms_messages` — honor `days_radius`
|
||||
|
||||
Today: `sms_client::fetch_messages_for_contact(contact, center_ts)`
|
||||
hardcodes `Duration::days(4)` (lines 31–37). The tool accepts
|
||||
`days_radius` and silently ignores it.
|
||||
|
||||
**Fix:** widen the signature to
|
||||
`fetch_messages_for_contact(contact, center_ts, days_radius)`. Tool
|
||||
plumbs through. Default 4 retained for back-compat.
|
||||
|
||||
#### `search_messages` — add date and contact_id filters
|
||||
|
||||
Today: ImageApi's `search_messages` only forwards `query`, `mode`,
|
||||
`limit` to SMS-API.
|
||||
|
||||
**Fix:** add `start_ts`, `end_ts`, `contact_id` parameters.
|
||||
- `contact_id` forwards directly to SMS-API
|
||||
(`/api/messages/search/?contact_id=`).
|
||||
- `start_ts` / `end_ts` are not natively accepted by SMS-API's search
|
||||
endpoint. Apply client-side post-filter on the response (Apollo's
|
||||
pattern: `chat_tools.py:670–680`). Bump the SMS-API `limit` to a
|
||||
larger fetch pool when a date filter is supplied so in-window matches
|
||||
aren't lost to out-of-window FTS rank.
|
||||
|
||||
---
|
||||
|
||||
## Implementation sequencing
|
||||
|
||||
Each step is independently mergeable.
|
||||
|
||||
### ImageApi PRs
|
||||
|
||||
1. **Split system-prompt assembly + neutralize user message.** Two
|
||||
named blocks; user message context-only. Default identity string
|
||||
added. Tests: golden snapshots of the resulting `system_content`
|
||||
with and without `custom_system_prompt`.
|
||||
2. **`system_prompt` field on chat request + swap/restore + amend
|
||||
persistence.** Mirrors `annotate_system_with_budget` pattern. Tests:
|
||||
round-trip system content unchanged in append mode; persisted in
|
||||
amend mode.
|
||||
3. **`fetch_messages_for_contact` honors `days_radius`.** Tool wires
|
||||
the param through. Tests: window math at the client level.
|
||||
4. **`ToolGateOpts` + per-tool description rewrites.** Description
|
||||
text changes are the bulk of the diff but no behavior change beyond
|
||||
gating.
|
||||
|
||||
### FileViewer-React PR
|
||||
|
||||
5. **Chat hook sends `system_prompt`; modal gets style-note input;
|
||||
built-in personas updated to specify shape.** The
|
||||
`useInsightChat.sendTurn` call site picks up the persona and
|
||||
includes it on every chat turn body. Style-note input is a one-shot
|
||||
suffix.
|
||||
|
||||
## Testing & verification
|
||||
|
||||
**Automated:**
|
||||
- Unit (Rust): swap-and-restore round-trip preserves stored transcript.
|
||||
- Unit (Rust): amend mode persists override into new insight row.
|
||||
- Unit (Rust): `fetch_messages_for_contact(days_radius=N)` produces a
|
||||
window of `2N` days centered on `center_ts`.
|
||||
- Unit (Rust): `build_tool_definitions(opts)` excludes gated tools when
|
||||
the corresponding flag is false.
|
||||
|
||||
**Manual:**
|
||||
- Run a chat turn against an existing insight without `system_prompt` →
|
||||
output unchanged from baseline.
|
||||
- Same insight, with override → output reflects new voice.
|
||||
- Re-open chat → original baked persona still authoritative (override
|
||||
was ephemeral).
|
||||
- Regenerate an insight with the journal persona → model's voice
|
||||
matches journal style; no "memory assistant" framing leaks through.
|
||||
- Toggle data presence (delete a row from `calendar_events`) → tool
|
||||
drops from the catalog on the next turn.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Default identity wording matters.** A too-neutral default ("Use the
|
||||
gathered context to write a summary") might produce flatter output
|
||||
than today's "personal photo memory assistant" framing for users
|
||||
who never set a persona. Mitigation: tune the default with a small
|
||||
set of test photos before merging.
|
||||
- **Persona-suffix style notes can contradict persona voice.** A user
|
||||
who picks `journal` (first person, warm) and adds the style note
|
||||
"respond in bullet points" will get a tonal collision. Acceptable —
|
||||
the user expressed a per-turn intent and we honor it. Document the
|
||||
composition rule in the persona-manager UI.
|
||||
- **Lazy data-presence probes add a per-turn `SELECT 1`.** Negligible
|
||||
on SQLite (sub-millisecond) but adds up across many turns. Cache the
|
||||
result for the turn's duration; don't re-probe per-tool.
|
||||
|
||||
## Open questions
|
||||
|
||||
None blocking. Items deferred to a possible follow-up cycle:
|
||||
|
||||
- Apollo parity for the same per-turn override pattern (already
|
||||
present; just needs RN client wiring on the photo path which is
|
||||
already proxy).
|
||||
- Tool consolidation (`search_messages` + `get_sms_messages` →
|
||||
single `search_messages` with optional date filter, Apollo-style).
|
||||
Considered and deferred — separate spec.
|
||||
@@ -640,6 +640,10 @@ pub struct ChatTurnHttpRequest {
|
||||
pub min_p: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub max_iterations: Option<usize>,
|
||||
/// Per-turn system-prompt override. Ephemeral in append mode,
|
||||
/// persisted in amend mode. See ChatTurnRequest for semantics.
|
||||
#[serde(default)]
|
||||
pub system_prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub amend: bool,
|
||||
}
|
||||
@@ -695,6 +699,7 @@ pub async fn chat_turn_handler(
|
||||
top_k: request.top_k,
|
||||
min_p: request.min_p,
|
||||
max_iterations: request.max_iterations,
|
||||
system_prompt: request.system_prompt.clone(),
|
||||
amend: request.amend,
|
||||
};
|
||||
|
||||
@@ -909,6 +914,7 @@ pub async fn chat_stream_handler(
|
||||
top_k: request.top_k,
|
||||
min_p: request.min_p,
|
||||
max_iterations: request.max_iterations,
|
||||
system_prompt: request.system_prompt.clone(),
|
||||
amend: request.amend,
|
||||
};
|
||||
|
||||
|
||||
@@ -45,6 +45,11 @@ pub struct ChatTurnRequest {
|
||||
pub top_k: Option<i32>,
|
||||
pub min_p: Option<f32>,
|
||||
pub max_iterations: Option<usize>,
|
||||
/// Per-turn system-prompt override. In append mode (default), applied
|
||||
/// ephemerally — original system message restored before persistence.
|
||||
/// In amend mode, persisted into the new insight row's system message.
|
||||
/// None / empty = no change.
|
||||
pub system_prompt: Option<String>,
|
||||
/// When true, write a new insight row (regenerating title) instead of
|
||||
/// updating training_messages on the existing row.
|
||||
pub amend: bool,
|
||||
@@ -359,10 +364,12 @@ impl InsightChatService {
|
||||
.map(|imgs| !imgs.is_empty())
|
||||
.unwrap_or(false);
|
||||
let offer_describe_tool = !is_hybrid && local_first_user_has_image;
|
||||
let tools = InsightGenerator::build_tool_definitions(
|
||||
offer_describe_tool,
|
||||
self.generator.apollo_enabled(),
|
||||
);
|
||||
// current_gate_opts(has_vision) sets gate_opts.has_vision = has_vision
|
||||
// and probes the per-table presence flags. Pass `offer_describe_tool`
|
||||
// directly — the `!is_hybrid && local_first_user_has_image` decision
|
||||
// is the chat-path's vision predicate.
|
||||
let gate_opts = self.generator.current_gate_opts(offer_describe_tool);
|
||||
let tools = InsightGenerator::build_tool_definitions(gate_opts);
|
||||
|
||||
// Image base64 only needed when describe_photo is on the menu. Load
|
||||
// lazily to avoid disk IO when the loop never invokes it.
|
||||
@@ -385,6 +392,13 @@ impl InsightChatService {
|
||||
// 7. Append the new user turn.
|
||||
messages.push(ChatMessage::user(req.user_message.clone()));
|
||||
|
||||
// Apply per-turn system-prompt override BEFORE the budget annotation
|
||||
// so the budget note attaches to the override, not the original.
|
||||
// The stash is consumed below before persistence (append mode) or
|
||||
// dropped (amend mode, where the override stays in place).
|
||||
let override_stash =
|
||||
apply_system_prompt_override(&mut messages, req.system_prompt.as_deref());
|
||||
|
||||
// Temporarily annotate the system message with this turn's iteration
|
||||
// budget so the model knows how many tool-calling rounds it has. We
|
||||
// restore the original content before persistence so the note doesn't
|
||||
@@ -481,6 +495,14 @@ impl InsightChatService {
|
||||
// before we persist so it doesn't snowball on each subsequent turn.
|
||||
restore_system_content(&mut messages, original_system_content);
|
||||
|
||||
// Append mode: undo the per-turn system-prompt override so the
|
||||
// stored transcript keeps the original baked persona. Amend mode:
|
||||
// keep the override in place — it becomes the new insight row's
|
||||
// system message.
|
||||
if !req.amend {
|
||||
restore_system_prompt_override(&mut messages, override_stash);
|
||||
}
|
||||
|
||||
// 9. Persist. Append mode rewrites the JSON blob in place; amend
|
||||
// mode regenerates the title and inserts a new insight row,
|
||||
// relying on store_insight to flip prior rows' is_current=false.
|
||||
@@ -790,10 +812,12 @@ impl InsightChatService {
|
||||
.map(|imgs| !imgs.is_empty())
|
||||
.unwrap_or(false);
|
||||
let offer_describe_tool = !is_hybrid && local_first_user_has_image;
|
||||
let tools = InsightGenerator::build_tool_definitions(
|
||||
offer_describe_tool,
|
||||
self.generator.apollo_enabled(),
|
||||
);
|
||||
// current_gate_opts(has_vision) sets gate_opts.has_vision = has_vision
|
||||
// and probes the per-table presence flags. Pass `offer_describe_tool`
|
||||
// directly — the `!is_hybrid && local_first_user_has_image` decision
|
||||
// is the chat-path's vision predicate.
|
||||
let gate_opts = self.generator.current_gate_opts(offer_describe_tool);
|
||||
let tools = InsightGenerator::build_tool_definitions(gate_opts);
|
||||
|
||||
let image_base64: Option<String> = if offer_describe_tool {
|
||||
self.generator.load_image_as_base64(&normalized).ok()
|
||||
@@ -812,6 +836,10 @@ impl InsightChatService {
|
||||
|
||||
messages.push(ChatMessage::user(req.user_message.clone()));
|
||||
|
||||
// Mirror chat_turn: per-turn override goes on first, budget note next.
|
||||
let override_stash =
|
||||
apply_system_prompt_override(&mut messages, req.system_prompt.as_deref());
|
||||
|
||||
let original_system_content = annotate_system_with_budget(&mut messages, max_iterations);
|
||||
|
||||
let mut tool_calls_made = 0usize;
|
||||
@@ -946,6 +974,13 @@ impl InsightChatService {
|
||||
// before we persist so it doesn't snowball on each subsequent turn.
|
||||
restore_system_content(&mut messages, original_system_content);
|
||||
|
||||
// Append mode: undo the per-turn system-prompt override (mirrors
|
||||
// chat_turn). Amend mode: keep the override — it becomes the new
|
||||
// insight row's system message.
|
||||
if !req.amend {
|
||||
restore_system_prompt_override(&mut messages, override_stash);
|
||||
}
|
||||
|
||||
// Persist.
|
||||
let json = serde_json::to_string(&messages)
|
||||
.map_err(|e| anyhow!("failed to serialize chat history: {}", e))?;
|
||||
@@ -1153,6 +1188,64 @@ fn restore_system_content(messages: &mut [ChatMessage], original: Option<String>
|
||||
}
|
||||
}
|
||||
|
||||
/// Receipt produced by [`apply_system_prompt_override`] so the caller can
|
||||
/// undo the override before persistence. Two variants because we either
|
||||
/// replaced an existing system message (need its original content) or
|
||||
/// prepended a synthetic one (need to pop it).
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum SystemPromptStash {
|
||||
Replaced { original: String },
|
||||
Prepended,
|
||||
}
|
||||
|
||||
/// Apply a per-turn `system_prompt` override to `messages` so the model
|
||||
/// sees the requested persona for this turn. Returns a stash the caller
|
||||
/// must pass to [`restore_system_prompt_override`] before persisting the
|
||||
/// transcript — without that step, append-mode chat would silently
|
||||
/// rewrite the stored persona.
|
||||
///
|
||||
/// No-op (returns `None`) when `override_prompt` is `None` or empty.
|
||||
pub(crate) fn apply_system_prompt_override(
|
||||
messages: &mut Vec<ChatMessage>,
|
||||
override_prompt: Option<&str>,
|
||||
) -> Option<SystemPromptStash> {
|
||||
let prompt = override_prompt
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())?
|
||||
.to_string();
|
||||
if let Some(first) = messages.first_mut()
|
||||
&& first.role == "system"
|
||||
{
|
||||
let original = std::mem::replace(&mut first.content, prompt);
|
||||
return Some(SystemPromptStash::Replaced { original });
|
||||
}
|
||||
messages.insert(0, ChatMessage::system(prompt));
|
||||
Some(SystemPromptStash::Prepended)
|
||||
}
|
||||
|
||||
/// Undo an override previously applied by [`apply_system_prompt_override`].
|
||||
/// No-op when `stash` is `None`.
|
||||
pub(crate) fn restore_system_prompt_override(
|
||||
messages: &mut Vec<ChatMessage>,
|
||||
stash: Option<SystemPromptStash>,
|
||||
) {
|
||||
let Some(stash) = stash else { return };
|
||||
match stash {
|
||||
SystemPromptStash::Replaced { original } => {
|
||||
if let Some(first) = messages.first_mut()
|
||||
&& first.role == "system"
|
||||
{
|
||||
first.content = original;
|
||||
}
|
||||
}
|
||||
SystemPromptStash::Prepended => {
|
||||
if matches!(messages.first(), Some(m) if m.role == "system") {
|
||||
messages.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// View returned to clients for chat-UI rendering.
|
||||
#[derive(Debug)]
|
||||
pub struct HistoryView {
|
||||
@@ -1386,4 +1479,94 @@ mod tests {
|
||||
let cut = find_raw_cut(&msgs, 2).expect("boundary cut should succeed");
|
||||
assert_eq!(cut, msgs.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_override_replaces_existing_system_message() {
|
||||
let mut msgs = vec![
|
||||
ChatMessage::system("original persona"),
|
||||
ChatMessage::user("hi"),
|
||||
];
|
||||
let stash = apply_system_prompt_override(&mut msgs, Some("new persona"));
|
||||
assert_eq!(msgs[0].content, "new persona");
|
||||
match stash {
|
||||
Some(SystemPromptStash::Replaced { original }) => {
|
||||
assert_eq!(original, "original persona");
|
||||
}
|
||||
other => panic!("expected Replaced, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_override_prepends_synthetic_when_missing() {
|
||||
let mut msgs = vec![ChatMessage::user("hi")];
|
||||
let stash = apply_system_prompt_override(&mut msgs, Some("new persona"));
|
||||
assert_eq!(msgs.len(), 2);
|
||||
assert_eq!(msgs[0].role, "system");
|
||||
assert_eq!(msgs[0].content, "new persona");
|
||||
assert!(matches!(stash, Some(SystemPromptStash::Prepended)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_override_no_op_when_none() {
|
||||
let mut msgs = vec![ChatMessage::system("sys"), ChatMessage::user("hi")];
|
||||
let stash = apply_system_prompt_override(&mut msgs, None);
|
||||
assert!(stash.is_none());
|
||||
assert_eq!(msgs[0].content, "sys");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_override_no_op_for_empty_string() {
|
||||
let mut msgs = vec![ChatMessage::system("sys")];
|
||||
let stash = apply_system_prompt_override(&mut msgs, Some(""));
|
||||
assert!(stash.is_none());
|
||||
assert_eq!(msgs[0].content, "sys");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_override_replaces_back() {
|
||||
let mut msgs = vec![ChatMessage::system("new"), ChatMessage::user("hi")];
|
||||
restore_system_prompt_override(
|
||||
&mut msgs,
|
||||
Some(SystemPromptStash::Replaced {
|
||||
original: "original".to_string(),
|
||||
}),
|
||||
);
|
||||
assert_eq!(msgs[0].content, "original");
|
||||
assert_eq!(msgs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_override_pops_synthetic() {
|
||||
let mut msgs = vec![ChatMessage::system("new"), ChatMessage::user("hi")];
|
||||
restore_system_prompt_override(&mut msgs, Some(SystemPromptStash::Prepended));
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(msgs[0].role, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_round_trip_preserves_original_system_message() {
|
||||
let mut msgs = vec![
|
||||
ChatMessage::system("original persona"),
|
||||
ChatMessage::user("first user"),
|
||||
assistant_text("first reply"),
|
||||
];
|
||||
let stash = apply_system_prompt_override(&mut msgs, Some("ephemeral persona"));
|
||||
assert_eq!(msgs[0].content, "ephemeral persona");
|
||||
restore_system_prompt_override(&mut msgs, stash);
|
||||
assert_eq!(msgs[0].content, "original persona");
|
||||
assert_eq!(msgs.len(), 3);
|
||||
assert_eq!(msgs[1].role, "user");
|
||||
assert_eq!(msgs[2].role, "assistant");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_with_synthetic_round_trip_drops_extra_message() {
|
||||
let mut msgs = vec![ChatMessage::user("first user")];
|
||||
let stash = apply_system_prompt_override(&mut msgs, Some("ephemeral"));
|
||||
assert_eq!(msgs.len(), 2);
|
||||
assert_eq!(msgs[0].role, "system");
|
||||
restore_system_prompt_override(&mut msgs, stash);
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(msgs[0].role, "user");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,31 +20,36 @@ impl SmsApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch messages for a specific contact within ±4 days of the given timestamp
|
||||
/// Falls back to all contacts if no messages found for the specific contact
|
||||
/// Messages are sorted by proximity to the center timestamp
|
||||
/// Compute a `[start, end]` unix-second window of `2 * radius_days`
|
||||
/// centered on `center_ts`. `radius_days < 1` is clamped to 1 to avoid
|
||||
/// degenerate zero-width windows.
|
||||
pub(crate) fn window_for_radius(center_ts: i64, radius_days: i64) -> (i64, i64) {
|
||||
let r = radius_days.max(1);
|
||||
let span = r * 86400;
|
||||
(center_ts - span, center_ts + span)
|
||||
}
|
||||
|
||||
/// Fetch messages for a specific contact within ±`radius_days` of the
|
||||
/// given timestamp. Falls back to all contacts when no messages found
|
||||
/// for the named contact. Sorted by proximity to the center timestamp.
|
||||
pub async fn fetch_messages_for_contact(
|
||||
&self,
|
||||
contact: Option<&str>,
|
||||
center_timestamp: i64,
|
||||
radius_days: i64,
|
||||
) -> Result<Vec<SmsMessage>> {
|
||||
use chrono::Duration;
|
||||
let effective_radius = radius_days.max(1);
|
||||
let (start_ts, end_ts) = Self::window_for_radius(center_timestamp, radius_days);
|
||||
|
||||
// Calculate ±4 days range around the center timestamp
|
||||
let center_dt = chrono::DateTime::from_timestamp(center_timestamp, 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid timestamp"))?;
|
||||
|
||||
let start_dt = center_dt - Duration::days(4);
|
||||
let end_dt = center_dt + Duration::days(4);
|
||||
|
||||
let start_ts = start_dt.timestamp();
|
||||
let end_ts = end_dt.timestamp();
|
||||
|
||||
// If contact specified, try fetching for that contact first
|
||||
if let Some(contact_name) = contact {
|
||||
log::info!(
|
||||
"Fetching SMS for contact: {} (±4 days from {})",
|
||||
"Fetching SMS for contact: {} (±{} days from {})",
|
||||
contact_name,
|
||||
effective_radius,
|
||||
center_dt.format("%Y-%m-%d %H:%M:%S")
|
||||
);
|
||||
let messages = self
|
||||
@@ -68,7 +73,8 @@ impl SmsApiClient {
|
||||
|
||||
// Fallback to all contacts
|
||||
log::info!(
|
||||
"Fetching all SMS messages (±4 days from {})",
|
||||
"Fetching all SMS messages (±{} days from {})",
|
||||
effective_radius,
|
||||
center_dt.format("%Y-%m-%d %H:%M:%S")
|
||||
);
|
||||
self.fetch_messages(start_ts, end_ts, None, Some(center_timestamp))
|
||||
@@ -255,19 +261,26 @@ impl SmsApiClient {
|
||||
/// - "fts5" keyword-only, supports phrase / prefix / boolean / NEAR
|
||||
/// - "semantic" embedding similarity
|
||||
/// - "hybrid" both merged via reciprocal rank fusion (recommended)
|
||||
pub async fn search_messages(
|
||||
///
|
||||
/// The SMS-API endpoint accepts `contact_id` natively; date filtering is
|
||||
/// the caller's responsibility (post-filter on the returned rows).
|
||||
pub async fn search_messages_with_contact(
|
||||
&self,
|
||||
query: &str,
|
||||
mode: &str,
|
||||
limit: usize,
|
||||
contact_id: Option<i64>,
|
||||
) -> Result<Vec<SmsSearchHit>> {
|
||||
let url = format!(
|
||||
let mut url = format!(
|
||||
"{}/api/messages/search/?q={}&mode={}&limit={}",
|
||||
self.base_url,
|
||||
urlencoding::encode(query),
|
||||
urlencoding::encode(mode),
|
||||
limit
|
||||
);
|
||||
if let Some(cid) = contact_id {
|
||||
url.push_str(&format!("&contact_id={}", cid));
|
||||
}
|
||||
|
||||
let mut request = self.client.get(&url);
|
||||
if let Some(token) = &self.token {
|
||||
@@ -379,3 +392,29 @@ struct SmsSearchResponse {
|
||||
#[serde(default)]
|
||||
search_method: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn window_for_radius_produces_2n_day_span() {
|
||||
let center: i64 = 1_700_000_000;
|
||||
let (start, end) = SmsApiClient::window_for_radius(center, 7);
|
||||
assert_eq!(end - start, 14 * 86400);
|
||||
assert_eq!(start + 7 * 86400, center);
|
||||
assert_eq!(end - 7 * 86400, center);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_for_radius_clamps_zero_to_one() {
|
||||
let (start, end) = SmsApiClient::window_for_radius(100_000, 0);
|
||||
assert_eq!(end - start, 2 * 86400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_for_radius_clamps_negative_to_one() {
|
||||
let (start, end) = SmsApiClient::window_for_radius(100_000, -7);
|
||||
assert_eq!(end - start, 2 * 86400);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use image_api::database::{
|
||||
SqliteInsightDao, SqliteKnowledgeDao, SqliteLocationHistoryDao, SqliteSearchHistoryDao,
|
||||
connect,
|
||||
};
|
||||
use image_api::faces::{FaceDao, SqliteFaceDao};
|
||||
use image_api::file_types::{IMAGE_EXTENSIONS, VIDEO_EXTENSIONS};
|
||||
use image_api::libraries::{self, Library};
|
||||
use image_api::tags::{SqliteTagDao, TagDao};
|
||||
@@ -182,6 +183,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
Arc::new(Mutex::new(Box::new(SqliteTagDao::default())));
|
||||
let knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>> =
|
||||
Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new())));
|
||||
let face_dao: Arc<Mutex<Box<dyn FaceDao>>> =
|
||||
Arc::new(Mutex::new(Box::new(SqliteFaceDao::new())));
|
||||
|
||||
// Pass the full library set so `resolve_full_path` probes every root,
|
||||
// even when --library restricts the walk. A rel_path shared across
|
||||
@@ -198,6 +201,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
location_dao,
|
||||
search_dao,
|
||||
tag_dao,
|
||||
face_dao,
|
||||
knowledge_dao,
|
||||
all_libs.clone(),
|
||||
);
|
||||
|
||||
@@ -75,6 +75,11 @@ pub trait DailySummaryDao: Sync + Send {
|
||||
context: &opentelemetry::Context,
|
||||
contact: &str,
|
||||
) -> Result<i64, DbError>;
|
||||
|
||||
/// Cheap presence check — returns true iff at least one daily summary row
|
||||
/// exists. Used by gating logic that only needs "is the table empty?",
|
||||
/// avoiding a `COUNT(*)` full scan on large corpora.
|
||||
fn has_any_summaries(&mut self, context: &opentelemetry::Context) -> Result<bool, DbError>;
|
||||
}
|
||||
|
||||
pub struct SqliteDailySummaryDao {
|
||||
@@ -454,6 +459,30 @@ impl DailySummaryDao for SqliteDailySummaryDao {
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn has_any_summaries(&mut self, context: &opentelemetry::Context) -> Result<bool, DbError> {
|
||||
trace_db_call(context, "query", "has_any_summaries", |_span| {
|
||||
let mut conn = self
|
||||
.connection
|
||||
.lock()
|
||||
.expect("Unable to get DailySummaryDao");
|
||||
|
||||
#[derive(QueryableByName)]
|
||||
struct ProbeResult {
|
||||
#[diesel(sql_type = diesel::sql_types::Integer)]
|
||||
#[allow(dead_code)]
|
||||
one: i32,
|
||||
}
|
||||
|
||||
let rows: Vec<ProbeResult> =
|
||||
diesel::sql_query("SELECT 1 as one FROM daily_conversation_summaries LIMIT 1")
|
||||
.load(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to probe daily summaries: {}", e))?;
|
||||
|
||||
Ok(!rows.is_empty())
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
}
|
||||
|
||||
// Helper structs for raw SQL queries
|
||||
|
||||
17
src/faces.rs
17
src/faces.rs
@@ -503,6 +503,10 @@ pub trait FaceDao: Send + Sync {
|
||||
into: i32,
|
||||
) -> anyhow::Result<Person>;
|
||||
|
||||
/// Cheap presence probe — returns true iff at least one face has been
|
||||
/// detected (excluding marker rows). Used by chat-tool gating.
|
||||
fn has_any_faces(&mut self, ctx: &opentelemetry::Context) -> anyhow::Result<bool>;
|
||||
|
||||
/// Resolve `(library_id, rel_path)` → `content_hash` via image_exif.
|
||||
/// Returns None when the photo hasn't been EXIF-indexed yet (no row
|
||||
/// in image_exif) or when the row exists but content_hash is NULL.
|
||||
@@ -1432,6 +1436,19 @@ impl FaceDao for SqliteFaceDao {
|
||||
})
|
||||
}
|
||||
|
||||
fn has_any_faces(&mut self, ctx: &opentelemetry::Context) -> anyhow::Result<bool> {
|
||||
let mut conn = self.connection.lock().expect("face dao lock");
|
||||
trace_db_call(ctx, "query", "has_any_faces", |_span| {
|
||||
face_detections::table
|
||||
.filter(face_detections::status.eq("detected"))
|
||||
.select(face_detections::id)
|
||||
.first::<i32>(conn.deref_mut())
|
||||
.optional()
|
||||
.map(|x| x.is_some())
|
||||
.with_context(|| "has_any_faces query")
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_content_hash(
|
||||
&mut self,
|
||||
ctx: &opentelemetry::Context,
|
||||
|
||||
@@ -1718,7 +1718,12 @@ mod tests {
|
||||
// Mock — files.rs tests don't exercise the date-override endpoints.
|
||||
// Returning a synthetic row keeps the trait satisfied without
|
||||
// depending on private DbError constructors.
|
||||
Ok(mock_exif_row(library_id, rel_path, Some(date_taken), Some("manual".to_string())))
|
||||
Ok(mock_exif_row(
|
||||
library_id,
|
||||
rel_path,
|
||||
Some(date_taken),
|
||||
Some("manual".to_string()),
|
||||
))
|
||||
}
|
||||
|
||||
fn clear_manual_date_taken(
|
||||
|
||||
20
src/main.rs
20
src/main.rs
@@ -995,10 +995,8 @@ async fn upload_image(
|
||||
}
|
||||
};
|
||||
let perceptual = perceptual_hash::compute(&uploaded_path);
|
||||
let resolved_date = date_resolver::resolve_date_taken(
|
||||
&uploaded_path,
|
||||
exif_data.date_taken,
|
||||
);
|
||||
let resolved_date =
|
||||
date_resolver::resolve_date_taken(&uploaded_path, exif_data.date_taken);
|
||||
let insert_exif = InsertImageExif {
|
||||
library_id: target_library.id,
|
||||
file_path: relative_path.clone(),
|
||||
@@ -1022,8 +1020,7 @@ async fn upload_image(
|
||||
size_bytes,
|
||||
phash_64: perceptual.map(|h| h.phash_64),
|
||||
dhash_64: perceptual.map(|h| h.dhash_64),
|
||||
date_taken_source: resolved_date
|
||||
.map(|r| r.source.as_str().to_string()),
|
||||
date_taken_source: resolved_date.map(|r| r.source.as_str().to_string()),
|
||||
};
|
||||
|
||||
if let Ok(mut dao) = exif_dao.lock() {
|
||||
@@ -1687,7 +1684,16 @@ fn create_thumbnails(libs: &[libraries::Library], excluded_dirs: &[String]) {
|
||||
]);
|
||||
|
||||
debug!("Generating video thumbnail: {:?}", thumb_path);
|
||||
generate_video_thumbnail(src, &thumb_path);
|
||||
if let Err(e) = generate_video_thumbnail(src, &thumb_path) {
|
||||
let sentinel = unsupported_thumbnail_sentinel(&thumb_path);
|
||||
error!(
|
||||
"Unable to thumbnail video {:?}: {}. Writing sentinel {:?}",
|
||||
src, e, sentinel
|
||||
);
|
||||
if let Err(se) = std::fs::write(&sentinel, b"") {
|
||||
warn!("Failed to write sentinel {:?}: {}", sentinel, se);
|
||||
}
|
||||
}
|
||||
video_span.end();
|
||||
} else if is_image(&entry) {
|
||||
match generate_image_thumbnail(src, &thumb_path) {
|
||||
|
||||
@@ -213,10 +213,7 @@ pub fn extract_date_from_filename(filename: &str) -> Option<DateTime<FixedOffset
|
||||
// dispatch on the source-app prefix instead.
|
||||
const NON_TIMESTAMP_PREFIXES: &[&str] = &["snapchat-"];
|
||||
let lower = filename.to_ascii_lowercase();
|
||||
if NON_TIMESTAMP_PREFIXES
|
||||
.iter()
|
||||
.any(|p| lower.starts_with(p))
|
||||
{
|
||||
if NON_TIMESTAMP_PREFIXES.iter().any(|p| lower.starts_with(p)) {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::database::{
|
||||
connect,
|
||||
};
|
||||
use crate::database::{PreviewDao, SqlitePreviewDao};
|
||||
use crate::faces;
|
||||
use crate::libraries::{self, Library, LibraryHealthMap};
|
||||
use crate::tags::{SqliteTagDao, TagDao};
|
||||
use crate::video::actors::{
|
||||
@@ -206,6 +207,8 @@ impl Default for AppState {
|
||||
Arc::new(Mutex::new(Box::new(SqliteTagDao::default())));
|
||||
let knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>> =
|
||||
Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new())));
|
||||
let face_dao: Arc<Mutex<Box<dyn faces::FaceDao>>> =
|
||||
Arc::new(Mutex::new(Box::new(faces::SqliteFaceDao::new())));
|
||||
|
||||
// Load base path and ensure the primary library row reflects it.
|
||||
let base_path = env::var("BASE_PATH").expect("BASE_PATH was not set in the env");
|
||||
@@ -231,6 +234,7 @@ impl Default for AppState {
|
||||
location_dao.clone(),
|
||||
search_dao.clone(),
|
||||
tag_dao.clone(),
|
||||
face_dao.clone(),
|
||||
knowledge_dao,
|
||||
libraries_vec.clone(),
|
||||
);
|
||||
@@ -348,6 +352,8 @@ impl AppState {
|
||||
Arc::new(Mutex::new(Box::new(SqliteTagDao::default())));
|
||||
let knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>> =
|
||||
Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new())));
|
||||
let face_dao: Arc<Mutex<Box<dyn faces::FaceDao>>> =
|
||||
Arc::new(Mutex::new(Box::new(faces::SqliteFaceDao::new())));
|
||||
|
||||
// Initialize test InsightGenerator with all data sources
|
||||
let base_path_str = base_path.to_string_lossy().to_string();
|
||||
@@ -370,6 +376,7 @@ impl AppState {
|
||||
location_dao.clone(),
|
||||
search_dao.clone(),
|
||||
tag_dao.clone(),
|
||||
face_dao.clone(),
|
||||
knowledge_dao,
|
||||
vec![test_lib],
|
||||
);
|
||||
|
||||
@@ -107,19 +107,39 @@ pub async fn create_playlist(video_path: &str, playlist_file: &str) -> Result<Ch
|
||||
result
|
||||
}
|
||||
|
||||
pub fn generate_video_thumbnail(path: &Path, destination: &Path) {
|
||||
Command::new("ffmpeg")
|
||||
pub fn generate_video_thumbnail(path: &Path, destination: &Path) -> std::io::Result<()> {
|
||||
// -vf scale + -c:v mjpeg mirrors `generate_image_thumbnail_ffmpeg`. The
|
||||
// filter chain matters as much as the scale does: without it, ffmpeg
|
||||
// hands the decoded frame straight to the mjpeg encoder, which rejects
|
||||
// any non-yuvj420p source ("Non full-range YUV is non-standard"). The
|
||||
// filter chain lets ffmpeg auto-insert the pix_fmt converter the
|
||||
// encoder needs, which is how the image-thumbnail path already handles
|
||||
// the same class of source.
|
||||
let output = Command::new("ffmpeg")
|
||||
.arg("-y")
|
||||
.arg("-ss")
|
||||
.arg("3")
|
||||
.arg("-i")
|
||||
.arg(path.to_str().unwrap())
|
||||
.arg(path)
|
||||
.arg("-vframes")
|
||||
.arg("1")
|
||||
.arg("-vf")
|
||||
.arg("scale=200:-1")
|
||||
.arg("-f")
|
||||
.arg("image2")
|
||||
.arg("-c:v")
|
||||
.arg("mjpeg")
|
||||
.arg(destination)
|
||||
.output()
|
||||
.expect("Failure to create video frame");
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"ffmpeg failed ({}): {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Use ffmpeg to extract a 200px-wide thumbnail from formats the `image` crate
|
||||
|
||||
Reference in New Issue
Block a user