Compare commits
4 Commits
master
...
2ff06413c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ff06413c6 | ||
|
|
66ea8490ab | ||
|
|
10ba706b39 | ||
|
|
9071d05932 |
@@ -48,6 +48,11 @@ pub struct GeneratePhotoInsightRequest {
|
|||||||
/// falls back to `DEFAULT_FEWSHOT_INSIGHT_IDS`.
|
/// falls back to `DEFAULT_FEWSHOT_INSIGHT_IDS`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub fewshot_insight_ids: Option<Vec<i32>>,
|
pub fewshot_insight_ids: Option<Vec<i32>>,
|
||||||
|
/// When true, drop `store_entity` / `store_fact` from the tool palette
|
||||||
|
/// for this run. Use for one-off explorations (caption-style prompts,
|
||||||
|
/// experimentation) that shouldn't pollute the persistent knowledge KB.
|
||||||
|
#[serde(default)]
|
||||||
|
pub disable_writes: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -390,6 +395,7 @@ pub async fn generate_agentic_insight_handler(
|
|||||||
request.backend.clone(),
|
request.backend.clone(),
|
||||||
fewshot_examples,
|
fewshot_examples,
|
||||||
fewshot_ids,
|
fewshot_ids,
|
||||||
|
request.disable_writes,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -642,6 +648,10 @@ pub struct ChatTurnHttpRequest {
|
|||||||
pub max_iterations: Option<usize>,
|
pub max_iterations: Option<usize>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub amend: bool,
|
pub amend: bool,
|
||||||
|
/// Drop store_entity / store_fact from the tool palette for this turn —
|
||||||
|
/// useful for hypothetical/exploration chats that shouldn't pollute the KB.
|
||||||
|
#[serde(default)]
|
||||||
|
pub disable_writes: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -696,6 +706,7 @@ pub async fn chat_turn_handler(
|
|||||||
min_p: request.min_p,
|
min_p: request.min_p,
|
||||||
max_iterations: request.max_iterations,
|
max_iterations: request.max_iterations,
|
||||||
amend: request.amend,
|
amend: request.amend,
|
||||||
|
disable_writes: request.disable_writes,
|
||||||
};
|
};
|
||||||
|
|
||||||
match app_state.insight_chat.chat_turn(chat_req).await {
|
match app_state.insight_chat.chat_turn(chat_req).await {
|
||||||
@@ -910,6 +921,7 @@ pub async fn chat_stream_handler(
|
|||||||
min_p: request.min_p,
|
min_p: request.min_p,
|
||||||
max_iterations: request.max_iterations,
|
max_iterations: request.max_iterations,
|
||||||
amend: request.amend,
|
amend: request.amend,
|
||||||
|
disable_writes: request.disable_writes,
|
||||||
};
|
};
|
||||||
|
|
||||||
let service = app_state.insight_chat.clone();
|
let service = app_state.insight_chat.clone();
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ pub struct ChatTurnRequest {
|
|||||||
/// When true, write a new insight row (regenerating title) instead of
|
/// When true, write a new insight row (regenerating title) instead of
|
||||||
/// updating training_messages on the existing row.
|
/// updating training_messages on the existing row.
|
||||||
pub amend: bool,
|
pub amend: bool,
|
||||||
|
/// When true, drop `store_entity` / `store_fact` from the tool palette
|
||||||
|
/// for this turn. Use to explore alternate phrasings or run
|
||||||
|
/// hypothetical chats without polluting the persistent KB.
|
||||||
|
pub disable_writes: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -362,6 +366,7 @@ impl InsightChatService {
|
|||||||
let tools = InsightGenerator::build_tool_definitions(
|
let tools = InsightGenerator::build_tool_definitions(
|
||||||
offer_describe_tool,
|
offer_describe_tool,
|
||||||
self.generator.apollo_enabled(),
|
self.generator.apollo_enabled(),
|
||||||
|
req.disable_writes,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Image base64 only needed when describe_photo is on the menu. Load
|
// Image base64 only needed when describe_photo is on the menu. Load
|
||||||
@@ -397,6 +402,9 @@ impl InsightChatService {
|
|||||||
// tighter and dispatching tools through the shared executor.
|
// tighter and dispatching tools through the shared executor.
|
||||||
let loop_span = tracer.start_with_context("ai.chat.loop", &insight_cx);
|
let loop_span = tracer.start_with_context("ai.chat.loop", &insight_cx);
|
||||||
let loop_cx = insight_cx.with_span(loop_span);
|
let loop_cx = insight_cx.with_span(loop_span);
|
||||||
|
// Memoize describe_photo for this turn so repeated calls don't
|
||||||
|
// produce conflicting visual descriptions in the assistant transcript.
|
||||||
|
let describe_cache: tokio::sync::Mutex<Option<String>> = tokio::sync::Mutex::new(None);
|
||||||
let mut tool_calls_made = 0usize;
|
let mut tool_calls_made = 0usize;
|
||||||
let mut iterations_used = 0usize;
|
let mut iterations_used = 0usize;
|
||||||
let mut last_prompt_eval_count: Option<i32> = None;
|
let mut last_prompt_eval_count: Option<i32> = None;
|
||||||
@@ -445,6 +453,7 @@ impl InsightChatService {
|
|||||||
&image_base64,
|
&image_base64,
|
||||||
&normalized,
|
&normalized,
|
||||||
&loop_cx,
|
&loop_cx,
|
||||||
|
Some(&describe_cache),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
messages.push(ChatMessage::tool_result(result));
|
messages.push(ChatMessage::tool_result(result));
|
||||||
@@ -793,6 +802,7 @@ impl InsightChatService {
|
|||||||
let tools = InsightGenerator::build_tool_definitions(
|
let tools = InsightGenerator::build_tool_definitions(
|
||||||
offer_describe_tool,
|
offer_describe_tool,
|
||||||
self.generator.apollo_enabled(),
|
self.generator.apollo_enabled(),
|
||||||
|
req.disable_writes,
|
||||||
);
|
);
|
||||||
|
|
||||||
let image_base64: Option<String> = if offer_describe_tool {
|
let image_base64: Option<String> = if offer_describe_tool {
|
||||||
@@ -814,6 +824,9 @@ impl InsightChatService {
|
|||||||
|
|
||||||
let original_system_content = annotate_system_with_budget(&mut messages, max_iterations);
|
let original_system_content = annotate_system_with_budget(&mut messages, max_iterations);
|
||||||
|
|
||||||
|
// Per-turn describe_photo memo, same intent as the non-streaming
|
||||||
|
// path: avoid replaying conflicting visual descriptions in transcript.
|
||||||
|
let describe_cache: tokio::sync::Mutex<Option<String>> = tokio::sync::Mutex::new(None);
|
||||||
let mut tool_calls_made = 0usize;
|
let mut tool_calls_made = 0usize;
|
||||||
let mut iterations_used = 0usize;
|
let mut iterations_used = 0usize;
|
||||||
let mut last_prompt_eval_count: Option<i32> = None;
|
let mut last_prompt_eval_count: Option<i32> = None;
|
||||||
@@ -889,6 +902,7 @@ impl InsightChatService {
|
|||||||
&image_base64,
|
&image_base64,
|
||||||
&normalized,
|
&normalized,
|
||||||
&cx,
|
&cx,
|
||||||
|
Some(&describe_cache),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let (result_preview, result_truncated) = truncate_tool_result(&result);
|
let (result_preview, result_truncated) = truncate_tool_result(&result);
|
||||||
@@ -1134,8 +1148,12 @@ fn annotate_system_with_budget(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let original = first.content.clone();
|
let original = first.content.clone();
|
||||||
|
// Formatted as its own section so small models don't skim past it the
|
||||||
|
// way they tend to with parenthetical asides at the bottom of a long prompt.
|
||||||
|
// Phrasing matches the base prompt: budget = capacity, not a constraint
|
||||||
|
// to conserve. Small models otherwise tend to stop early.
|
||||||
first.content = format!(
|
first.content = format!(
|
||||||
"{}\n\n(Budget for this chat turn: up to {} tool-calling iterations. Produce your final reply before the budget is exhausted.)",
|
"{}\n\n## Budget for this chat turn\n\nYou have up to {} iterations available. Use as many as the question warrants for context-gathering, and reserve the last one for your reply.",
|
||||||
first.content, max_iterations
|
first.content, max_iterations
|
||||||
);
|
);
|
||||||
Some(original)
|
Some(original)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,22 +20,24 @@ impl SmsApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch messages for a specific contact within ±4 days of the given timestamp
|
/// Fetch messages for a specific contact within ±`days_radius` days of
|
||||||
/// Falls back to all contacts if no messages found for the specific contact
|
/// the given timestamp (defaults to ±4 days when `None`). Falls back to
|
||||||
/// Messages are sorted by proximity to the center timestamp
|
/// all contacts if no messages are found for the specified contact.
|
||||||
|
/// Messages are sorted by proximity to the center timestamp.
|
||||||
pub async fn fetch_messages_for_contact(
|
pub async fn fetch_messages_for_contact(
|
||||||
&self,
|
&self,
|
||||||
contact: Option<&str>,
|
contact: Option<&str>,
|
||||||
center_timestamp: i64,
|
center_timestamp: i64,
|
||||||
|
days_radius: Option<i64>,
|
||||||
) -> Result<Vec<SmsMessage>> {
|
) -> Result<Vec<SmsMessage>> {
|
||||||
use chrono::Duration;
|
use chrono::Duration;
|
||||||
|
|
||||||
// Calculate ±4 days range around the center timestamp
|
let radius = days_radius.unwrap_or(4).clamp(1, 30);
|
||||||
let center_dt = chrono::DateTime::from_timestamp(center_timestamp, 0)
|
let center_dt = chrono::DateTime::from_timestamp(center_timestamp, 0)
|
||||||
.ok_or_else(|| anyhow::anyhow!("Invalid timestamp"))?;
|
.ok_or_else(|| anyhow::anyhow!("Invalid timestamp"))?;
|
||||||
|
|
||||||
let start_dt = center_dt - Duration::days(4);
|
let start_dt = center_dt - Duration::days(radius);
|
||||||
let end_dt = center_dt + Duration::days(4);
|
let end_dt = center_dt + Duration::days(radius);
|
||||||
|
|
||||||
let start_ts = start_dt.timestamp();
|
let start_ts = start_dt.timestamp();
|
||||||
let end_ts = end_dt.timestamp();
|
let end_ts = end_dt.timestamp();
|
||||||
@@ -43,8 +45,9 @@ impl SmsApiClient {
|
|||||||
// If contact specified, try fetching for that contact first
|
// If contact specified, try fetching for that contact first
|
||||||
if let Some(contact_name) = contact {
|
if let Some(contact_name) = contact {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Fetching SMS for contact: {} (±4 days from {})",
|
"Fetching SMS for contact: {} (±{} days from {})",
|
||||||
contact_name,
|
contact_name,
|
||||||
|
radius,
|
||||||
center_dt.format("%Y-%m-%d %H:%M:%S")
|
center_dt.format("%Y-%m-%d %H:%M:%S")
|
||||||
);
|
);
|
||||||
let messages = self
|
let messages = self
|
||||||
@@ -68,7 +71,8 @@ impl SmsApiClient {
|
|||||||
|
|
||||||
// Fallback to all contacts
|
// Fallback to all contacts
|
||||||
log::info!(
|
log::info!(
|
||||||
"Fetching all SMS messages (±4 days from {})",
|
"Fetching all SMS messages (±{} days from {})",
|
||||||
|
radius,
|
||||||
center_dt.format("%Y-%m-%d %H:%M:%S")
|
center_dt.format("%Y-%m-%d %H:%M:%S")
|
||||||
);
|
);
|
||||||
self.fetch_messages(start_ts, end_ts, None, Some(center_timestamp))
|
self.fetch_messages(start_ts, end_ts, None, Some(center_timestamp))
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
None,
|
None,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
|
false, // disable_writes — keep KB writes on for the population job
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1198,17 +1198,52 @@ impl ExifDao for SqliteExifDao {
|
|||||||
|
|
||||||
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
|
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
|
||||||
|
|
||||||
diesel::update(
|
let result = diesel::update(
|
||||||
image_exif
|
image_exif
|
||||||
.filter(library_id.eq(library_id_val))
|
.filter(library_id.eq(library_id_val))
|
||||||
.filter(rel_path.eq(rel_path_val)),
|
.filter(rel_path.eq(rel_path_val)),
|
||||||
)
|
)
|
||||||
.set((date_taken.eq(date_taken_val), date_taken_source.eq(source)))
|
.set((date_taken.eq(date_taken_val), date_taken_source.eq(source)))
|
||||||
.execute(connection.deref_mut())
|
.execute(connection.deref_mut());
|
||||||
.map(|_| ())
|
|
||||||
.map_err(|_| anyhow::anyhow!("Update error"))
|
match result {
|
||||||
|
Ok(rows) => {
|
||||||
|
// Surface zero-row updates as a warning rather than a
|
||||||
|
// silent success. They mean the (library_id, rel_path)
|
||||||
|
// row was deleted between the `get_rows_needing_date_
|
||||||
|
// backfill` query and this update — rare but possible
|
||||||
|
// when the file watcher is racing the drain. The drain
|
||||||
|
// shouldn't treat that as a hard error, so still
|
||||||
|
// return Ok(()).
|
||||||
|
if rows == 0 {
|
||||||
|
log::debug!(
|
||||||
|
"backfill_date_taken: 0 rows matched lib={} {} \
|
||||||
|
(row likely retired by missing-file scan)",
|
||||||
|
library_id_val,
|
||||||
|
rel_path_val
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
// Preserve the diesel error in the chain so warnings at
|
||||||
|
// the call site can articulate the cause (a flat "Update
|
||||||
|
// error" was useless for triage).
|
||||||
|
Err(e) => Err(anyhow::anyhow!(
|
||||||
|
"diesel update failed (lib={}, rel_path={}, date_taken={}, source={}): {}",
|
||||||
|
library_id_val,
|
||||||
|
rel_path_val,
|
||||||
|
date_taken_val,
|
||||||
|
source,
|
||||||
|
e
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|e| {
|
||||||
|
// Log before the anyhow message gets stripped by the
|
||||||
|
// DbError-only return type.
|
||||||
|
log::warn!("backfill_date_taken: {}", e);
|
||||||
|
DbError::new(DbErrorKind::UpdateError)
|
||||||
})
|
})
|
||||||
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_manual_date_taken(
|
fn set_manual_date_taken(
|
||||||
|
|||||||
@@ -1718,7 +1718,12 @@ mod tests {
|
|||||||
// Mock — files.rs tests don't exercise the date-override endpoints.
|
// Mock — files.rs tests don't exercise the date-override endpoints.
|
||||||
// Returning a synthetic row keeps the trait satisfied without
|
// Returning a synthetic row keeps the trait satisfied without
|
||||||
// depending on private DbError constructors.
|
// 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(
|
fn clear_manual_date_taken(
|
||||||
|
|||||||
@@ -995,10 +995,8 @@ async fn upload_image(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let perceptual = perceptual_hash::compute(&uploaded_path);
|
let perceptual = perceptual_hash::compute(&uploaded_path);
|
||||||
let resolved_date = date_resolver::resolve_date_taken(
|
let resolved_date =
|
||||||
&uploaded_path,
|
date_resolver::resolve_date_taken(&uploaded_path, exif_data.date_taken);
|
||||||
exif_data.date_taken,
|
|
||||||
);
|
|
||||||
let insert_exif = InsertImageExif {
|
let insert_exif = InsertImageExif {
|
||||||
library_id: target_library.id,
|
library_id: target_library.id,
|
||||||
file_path: relative_path.clone(),
|
file_path: relative_path.clone(),
|
||||||
@@ -1022,8 +1020,7 @@ async fn upload_image(
|
|||||||
size_bytes,
|
size_bytes,
|
||||||
phash_64: perceptual.map(|h| h.phash_64),
|
phash_64: perceptual.map(|h| h.phash_64),
|
||||||
dhash_64: perceptual.map(|h| h.dhash_64),
|
dhash_64: perceptual.map(|h| h.dhash_64),
|
||||||
date_taken_source: resolved_date
|
date_taken_source: resolved_date.map(|r| r.source.as_str().to_string()),
|
||||||
.map(|r| r.source.as_str().to_string()),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Ok(mut dao) = exif_dao.lock() {
|
if let Ok(mut dao) = exif_dao.lock() {
|
||||||
|
|||||||
Reference in New Issue
Block a user