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:
@@ -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