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`.
|
||||
#[serde(default)]
|
||||
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)]
|
||||
@@ -390,6 +395,7 @@ pub async fn generate_agentic_insight_handler(
|
||||
request.backend.clone(),
|
||||
fewshot_examples,
|
||||
fewshot_ids,
|
||||
request.disable_writes,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -642,6 +648,10 @@ pub struct ChatTurnHttpRequest {
|
||||
pub max_iterations: Option<usize>,
|
||||
#[serde(default)]
|
||||
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)]
|
||||
@@ -696,6 +706,7 @@ pub async fn chat_turn_handler(
|
||||
min_p: request.min_p,
|
||||
max_iterations: request.max_iterations,
|
||||
amend: request.amend,
|
||||
disable_writes: request.disable_writes,
|
||||
};
|
||||
|
||||
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,
|
||||
max_iterations: request.max_iterations,
|
||||
amend: request.amend,
|
||||
disable_writes: request.disable_writes,
|
||||
};
|
||||
|
||||
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
|
||||
/// updating training_messages on the existing row.
|
||||
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)]
|
||||
@@ -362,6 +366,7 @@ impl InsightChatService {
|
||||
let tools = InsightGenerator::build_tool_definitions(
|
||||
offer_describe_tool,
|
||||
self.generator.apollo_enabled(),
|
||||
req.disable_writes,
|
||||
);
|
||||
|
||||
// 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.
|
||||
let loop_span = tracer.start_with_context("ai.chat.loop", &insight_cx);
|
||||
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 iterations_used = 0usize;
|
||||
let mut last_prompt_eval_count: Option<i32> = None;
|
||||
@@ -445,6 +453,7 @@ impl InsightChatService {
|
||||
&image_base64,
|
||||
&normalized,
|
||||
&loop_cx,
|
||||
Some(&describe_cache),
|
||||
)
|
||||
.await;
|
||||
messages.push(ChatMessage::tool_result(result));
|
||||
@@ -793,6 +802,7 @@ impl InsightChatService {
|
||||
let tools = InsightGenerator::build_tool_definitions(
|
||||
offer_describe_tool,
|
||||
self.generator.apollo_enabled(),
|
||||
req.disable_writes,
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
// 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 iterations_used = 0usize;
|
||||
let mut last_prompt_eval_count: Option<i32> = None;
|
||||
@@ -889,6 +902,7 @@ impl InsightChatService {
|
||||
&image_base64,
|
||||
&normalized,
|
||||
&cx,
|
||||
Some(&describe_cache),
|
||||
)
|
||||
.await;
|
||||
let (result_preview, result_truncated) = truncate_tool_result(&result);
|
||||
@@ -1134,8 +1148,12 @@ fn annotate_system_with_budget(
|
||||
return None;
|
||||
}
|
||||
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!(
|
||||
"{}\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
|
||||
);
|
||||
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
|
||||
/// Falls back to all contacts if no messages found for the specific contact
|
||||
/// Messages are sorted by proximity to the center timestamp
|
||||
/// Fetch messages for a specific contact within ±`days_radius` days of
|
||||
/// the given timestamp (defaults to ±4 days when `None`). Falls back to
|
||||
/// 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(
|
||||
&self,
|
||||
contact: Option<&str>,
|
||||
center_timestamp: i64,
|
||||
days_radius: Option<i64>,
|
||||
) -> Result<Vec<SmsMessage>> {
|
||||
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)
|
||||
.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_dt = center_dt - Duration::days(radius);
|
||||
let end_dt = center_dt + Duration::days(radius);
|
||||
|
||||
let start_ts = start_dt.timestamp();
|
||||
let end_ts = end_dt.timestamp();
|
||||
@@ -43,8 +45,9 @@ impl SmsApiClient {
|
||||
// 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,
|
||||
radius,
|
||||
center_dt.format("%Y-%m-%d %H:%M:%S")
|
||||
);
|
||||
let messages = self
|
||||
@@ -68,7 +71,8 @@ impl SmsApiClient {
|
||||
|
||||
// Fallback to all contacts
|
||||
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")
|
||||
);
|
||||
self.fetch_messages(start_ts, end_ts, None, Some(center_timestamp))
|
||||
|
||||
@@ -331,6 +331,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
None,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
false, // disable_writes — keep KB writes on for the population job
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -1198,17 +1198,52 @@ impl ExifDao for SqliteExifDao {
|
||||
|
||||
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
|
||||
|
||||
diesel::update(
|
||||
let result = diesel::update(
|
||||
image_exif
|
||||
.filter(library_id.eq(library_id_val))
|
||||
.filter(rel_path.eq(rel_path_val)),
|
||||
)
|
||||
.set((date_taken.eq(date_taken_val), date_taken_source.eq(source)))
|
||||
.execute(connection.deref_mut())
|
||||
.map(|_| ())
|
||||
.map_err(|_| anyhow::anyhow!("Update error"))
|
||||
.execute(connection.deref_mut());
|
||||
|
||||
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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user