Switch read paths to the true-instant date contract (ticket #119)

- New src/date_view.rs: pure read-side functions (row_true_instant,
  row_display_wall, row_display_day, filename_true_instant) applying the
  per-source convention table for unconverted rows and the new-contract
  triple for converted rows (ADR-0001).
- /memories: grouping on the row's true instant rendered in the owner's
  display zone (prefs -> app default), not the client device offset.
  SQL is a coarse interval pre-filter over the full years-back window;
  exact month-day/week/month matching across past years happens in Rust
  (memories::day_in_span, sqlite_w_week).
- Reels: ReelSelector::Memories carries an IANA display_tz instead of a
  fixed minute offset; pre-gen params read REEL_PREGEN_DISPLAY_TZ; the
  tz_offset_minutes ledger column stores the zone offset at render time.
- Insights: prompt-facing dates render as the display wall; the
  proximity timestamp and chat context block use the true instant;
  InsightGenerator reads the owner's display zone from prefs per call.
- GpsPhotoSummary carries the new-contract triple alongside the frozen
  legacy date_taken.

Closes #119
This commit is contained in:
Cameron Cordes committed 2026-09-26 19:28:26 -04:00
1 parent cd375f18db
commit af87765bbb
13 files changed
+1590 -301

No files matched your search

+89 -59
View File
@@ -1,12 +1,13 @@
use anyhow::Result;
use base64::Engine as _;
use chrono::{DateTime, Local, NaiveDate, Utc};
use chrono::{DateTime, Local, NaiveDate, TimeZone, Utc};
use image::ImageFormat;
use opentelemetry::KeyValue;
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
use serde::Deserialize;
use std::fs::File;
use std::io::Cursor;
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use crate::ai::apollo_client::{ApolloClient, ApolloPlace};
@@ -23,7 +24,7 @@ use crate::database::{
LocationHistoryDao, SearchHistoryDao,
};
use crate::libraries::Library;
use crate::memories::{FilenameDate, extract_date_from_filename};
use crate::memories::extract_date_from_filename;
use crate::otel::global_tracer;
use crate::tags::TagDao;
use crate::utils::{earliest_fs_time, normalize_path, retry_with_backoff, retry_with_backoff_if};
@@ -161,6 +162,10 @@ pub struct InsightGenerator {
// the per-persona "reviewed-only" strict-mode toggle).
persona_dao: Arc<Mutex<Box<dyn crate::database::PersonaDao>>>,
/// Owner display-zone prefs (ADR-0001): prompt dates and the chat
/// context block render in this zone.
user_ai_prefs_dao: Arc<Mutex<Box<dyn crate::database::UserAiPrefsDao>>>,
libraries: Vec<Library>,
}
@@ -201,6 +206,7 @@ impl InsightGenerator {
face_dao: Arc<Mutex<Box<dyn crate::faces::FaceDao>>>,
knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>>,
persona_dao: Arc<Mutex<Box<dyn crate::database::PersonaDao>>>,
user_ai_prefs_dao: Arc<Mutex<Box<dyn crate::database::UserAiPrefsDao>>>,
libraries: Vec<Library>,
) -> Self {
Self {
@@ -219,10 +225,86 @@ impl InsightGenerator {
face_dao,
knowledge_dao,
persona_dao,
user_ai_prefs_dao,
libraries,
}
}
/// The owner's display zone (ADR-0001): prefs `display_tz` when set and
/// valid, the app default otherwise. Read per call so a pref change
/// takes effect without a restart; a DB error degrades to the default.
pub fn display_zone(&self) -> chrono_tz::Tz {
let name = {
let stored = self
.user_ai_prefs_dao
.lock()
.ok()
.and_then(|mut dao| dao.get_prefs(&opentelemetry::Context::new()).ok().flatten())
.and_then(|p| p.display_tz);
crate::camera_tz::display_zone_for(stored.as_deref())
};
chrono_tz::Tz::from_str(&name).unwrap_or(chrono_tz::Tz::America__New_York)
}
/// The photo's date anchor pair (ADR-0001), shared by the non-agentic
/// and agentic insight paths:
/// - the **true instant** — the row's new-contract instant when present,
/// the legacy value under its per-source convention while unconverted,
/// then filename / fs-time / now — used as the zero-shift anchor for
/// the SMS / location / calendar proximity windows;
/// - the **prompt-facing display wall** — the row's display wall (camera
/// wall when the row carries an offset, display-zone rendering
/// otherwise) — the date the AI is told, matching what the owner sees.
fn resolve_date_anchor(
&self,
exif: &Option<crate::database::models::ImageExif>,
file_path: &str,
log_label: &str,
) -> (i64, NaiveDate) {
let display_zone = self.display_zone();
let server_zone = crate::camera_tz::server_zone();
let timestamp = exif
.as_ref()
.and_then(|e| crate::date_view::row_true_instant(e, display_zone, server_zone))
.unwrap_or_else(|| {
log::warn!("No date in EXIF for {}, trying filename", file_path);
extract_date_from_filename(file_path)
.and_then(|fd| crate::date_view::filename_true_instant(&fd, server_zone))
.or_else(|| {
let full_path = self.resolve_full_path(file_path)?;
File::open(&full_path)
.and_then(|f| f.metadata())
.inspect_err(|e| {
log::warn!(
"Failed to get file timestamp for {} {}: {}",
log_label,
file_path,
e
)
})
.ok()
.and_then(|m| earliest_fs_time(&m))
.map(|t| DateTime::<Utc>::from(t).timestamp())
})
.unwrap_or_else(|| Utc::now().timestamp())
});
let date_taken = exif
.as_ref()
.and_then(|e| crate::date_view::row_display_wall(e, display_zone, server_zone))
.map(|w| w.date())
.or_else(|| {
display_zone
.timestamp_opt(timestamp, 0)
.single()
.map(|dt| dt.date_naive())
})
.unwrap_or_else(|| Utc::now().date_naive());
(timestamp, date_taken)
}
/// Accessor for the insight DAO (used by async job completion to
/// look up the stored insight id).
pub fn insight_dao(&self) -> &Arc<Mutex<Box<dyn InsightDao>>> {
@@ -1055,35 +1137,8 @@ impl InsightGenerator {
.map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))?
};
// Get full timestamp for proximity-based message filtering
let timestamp = if let Some(ts) = exif.as_ref().and_then(|e| e.date_taken) {
ts
} else {
log::warn!("No date_taken in EXIF for {}, trying filename", file_path);
extract_date_from_filename(&file_path)
.map(FilenameDate::legacy_timestamp)
.or_else(|| {
let full_path = self.resolve_full_path(&file_path)?;
File::open(&full_path)
.and_then(|f| f.metadata())
.inspect_err(|e| {
log::warn!(
"Failed to get file timestamp for insight {}: {}",
file_path,
e
)
})
.ok()
.and_then(|m| earliest_fs_time(&m))
.map(|t| DateTime::<Utc>::from(t).timestamp())
})
.unwrap_or_else(|| Utc::now().timestamp())
};
let date_taken = DateTime::from_timestamp(timestamp, 0)
.map(|dt| dt.date_naive())
.unwrap_or_else(|| Utc::now().date_naive());
// True-instant anchor + prompt-facing display wall (ADR-0001).
let (timestamp, date_taken) = self.resolve_date_anchor(&exif, &file_path, "insight");
// 3. Extract contact name from file path
let contact = Self::extract_contact_from_path(&file_path);
@@ -4147,34 +4202,9 @@ Return ONLY the summary, nothing else."#,
.map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))?
};
// 4. Extract timestamp and contact
let timestamp = if let Some(ts) = exif.as_ref().and_then(|e| e.date_taken) {
ts
} else {
log::warn!("No date_taken in EXIF for {}, trying filename", file_path);
extract_date_from_filename(&file_path)
.map(FilenameDate::legacy_timestamp)
.or_else(|| {
let full_path = self.resolve_full_path(&file_path)?;
File::open(&full_path)
.and_then(|f| f.metadata())
.inspect_err(|e| {
log::warn!(
"Failed to get file timestamp for agentic insight {}: {}",
file_path,
e
)
})
.ok()
.and_then(|m| earliest_fs_time(&m))
.map(|t| DateTime::<Utc>::from(t).timestamp())
})
.unwrap_or_else(|| Utc::now().timestamp())
};
let date_taken = DateTime::from_timestamp(timestamp, 0)
.map(|dt| dt.date_naive())
.unwrap_or_else(|| Utc::now().date_naive());
// 4. Extract date and contact. The agentic prompt only needs the
// display wall (ADR-0001) — its proximity windows are tool-driven.
let (_, date_taken) = self.resolve_date_anchor(&exif, &file_path, "agentic insight");
let contact = Self::extract_contact_from_path(&file_path);
log::info!("Agentic: date_taken={}, contact={:?}", date_taken, contact);