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:
1 parent
cd375f18db
commit
af87765bbb
13 files changed
+1590
-301
No files matched your search
+185
-17
@@ -1,5 +1,5 @@
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use chrono::Utc;
|
||||
use chrono::{TimeZone, Utc};
|
||||
use opentelemetry::KeyValue;
|
||||
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
|
||||
use std::collections::HashMap;
|
||||
@@ -1175,7 +1175,12 @@ impl InsightChatService {
|
||||
let image_base64: Option<String> = self.generator.load_image_as_base64(&normalized).ok();
|
||||
|
||||
let exif = self.generator.fetch_exif(&normalized);
|
||||
let date_taken_str = resolve_date_taken_for_context(&exif, &normalized);
|
||||
let date_taken_str = resolve_date_taken_for_context(
|
||||
&exif,
|
||||
&normalized,
|
||||
self.generator.display_zone(),
|
||||
crate::camera_tz::server_zone(),
|
||||
);
|
||||
let gps = exif
|
||||
.as_ref()
|
||||
.and_then(|e| match (e.gps_latitude, e.gps_longitude) {
|
||||
@@ -1644,7 +1649,12 @@ impl InsightChatService {
|
||||
// would otherwise default to today's date and miss every
|
||||
// historical photo).
|
||||
let exif = self.generator.fetch_exif(&normalized);
|
||||
let date_taken_str = resolve_date_taken_for_context(&exif, &normalized);
|
||||
let date_taken_str = resolve_date_taken_for_context(
|
||||
&exif,
|
||||
&normalized,
|
||||
self.generator.display_zone(),
|
||||
crate::camera_tz::server_zone(),
|
||||
);
|
||||
let gps = exif
|
||||
.as_ref()
|
||||
.and_then(|e| match (e.gps_latitude, e.gps_longitude) {
|
||||
@@ -2020,24 +2030,33 @@ fn build_bootstrap_system_message(
|
||||
}
|
||||
|
||||
/// Resolve a human-readable `YYYY-MM-DD` date string for the photo
|
||||
/// context block. Waterfall: EXIF `date_taken` → filename pattern →
|
||||
/// `None`. The fs-time fallback that `generate_agentic_insight_for_photo`
|
||||
/// context block (ADR-0001). Waterfall: the row's display wall (new
|
||||
/// contract when converted, per-source convention while unconverted) →
|
||||
/// filename pattern (wall patterns as-is, unix-timestamp patterns in the
|
||||
/// display zone) → `None`. The fs-time fallback that insight generation
|
||||
/// uses is intentionally NOT applied here — for chat we'd rather show
|
||||
/// "unknown" than a misleading inode mtime as the photo's date.
|
||||
fn resolve_date_taken_for_context(
|
||||
exif: &Option<crate::database::models::ImageExif>,
|
||||
file_path: &str,
|
||||
display_zone: chrono_tz::Tz,
|
||||
server_zone: Option<chrono_tz::Tz>,
|
||||
) -> Option<String> {
|
||||
let from_exif = exif
|
||||
.as_ref()
|
||||
.and_then(|e| e.date_taken)
|
||||
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
|
||||
.map(|dt| dt.format("%Y-%m-%d").to_string());
|
||||
if from_exif.is_some() {
|
||||
return from_exif;
|
||||
if let Some(e) = exif
|
||||
&& let Some(wall) = crate::date_view::row_display_wall(e, display_zone, server_zone)
|
||||
{
|
||||
return Some(wall.format("%Y-%m-%d").to_string());
|
||||
}
|
||||
crate::memories::extract_date_from_filename(file_path)
|
||||
.map(|fd| fd.legacy_datetime().format("%Y-%m-%d").to_string())
|
||||
crate::memories::extract_date_from_filename(file_path).and_then(|fd| {
|
||||
let wall = match &fd {
|
||||
crate::memories::FilenameDate::Wall(w) => *w,
|
||||
crate::memories::FilenameDate::Utc(dt) => display_zone
|
||||
.timestamp_opt(dt.timestamp(), 0)
|
||||
.single()?
|
||||
.naive_local(),
|
||||
};
|
||||
Some(wall.format("%Y-%m-%d").to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate a stored→effective backend transition for a chat continuation.
|
||||
@@ -3385,7 +3404,12 @@ mod tests {
|
||||
original_camera_offset_minutes: None,
|
||||
original_date_taken_tz_source: None,
|
||||
});
|
||||
let out = resolve_date_taken_for_context(&exif, "Screenshot_2014-06-01.png");
|
||||
let out = resolve_date_taken_for_context(
|
||||
&exif,
|
||||
"Screenshot_2014-06-01.png",
|
||||
chrono_tz::Tz::America__New_York,
|
||||
Some(chrono_tz::Tz::America__New_York),
|
||||
);
|
||||
assert_eq!(out.as_deref(), Some("2021-08-15"));
|
||||
}
|
||||
|
||||
@@ -3394,13 +3418,157 @@ mod tests {
|
||||
// memories::extract_date_from_filename requires date+time in
|
||||
// the filename — date-only patterns aren't matched. Use the
|
||||
// canonical screenshot pattern for the regression case.
|
||||
let out = resolve_date_taken_for_context(&None, "Screenshot_2014-06-01-20-44-50.png");
|
||||
let out = resolve_date_taken_for_context(
|
||||
&None,
|
||||
"Screenshot_2014-06-01-20-44-50.png",
|
||||
chrono_tz::Tz::America__New_York,
|
||||
Some(chrono_tz::Tz::America__New_York),
|
||||
);
|
||||
assert_eq!(out.as_deref(), Some("2014-06-01"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_taken_for_context_returns_none_when_neither_source() {
|
||||
let out = resolve_date_taken_for_context(&None, "DSC_5171.JPG");
|
||||
let out = resolve_date_taken_for_context(
|
||||
&None,
|
||||
"DSC_5171.JPG",
|
||||
chrono_tz::Tz::America__New_York,
|
||||
Some(chrono_tz::Tz::America__New_York),
|
||||
);
|
||||
assert!(out.is_none());
|
||||
}
|
||||
|
||||
/// AC-4 / AC-5: a Japan-offset (+9 h) photo anchored at local 21:00.
|
||||
/// The AI is told the camera wall clock (instant + offset — a midnight
|
||||
/// rollover relative to the owner's display zone is expected and
|
||||
/// correct), while the SMS/location/calendar windows anchor on the
|
||||
/// true instant with zero offset math.
|
||||
#[test]
|
||||
fn japan_offset_row_ai_date_is_camera_wall_and_anchor_unshifted() {
|
||||
use chrono::NaiveDate;
|
||||
|
||||
// 2024-07-05 23:30 UTC = 2024-07-06 08:30 JST = 2024-07-05 19:30 EDT.
|
||||
let instant = NaiveDate::from_ymd_opt(2024, 7, 5)
|
||||
.unwrap()
|
||||
.and_hms_opt(23, 30, 0)
|
||||
.unwrap()
|
||||
.and_utc();
|
||||
let date_utc = instant.timestamp();
|
||||
|
||||
let row = crate::database::models::ImageExif {
|
||||
id: 0,
|
||||
library_id: 1,
|
||||
file_path: "tokyo.jpg".to_string(),
|
||||
camera_make: None,
|
||||
camera_model: None,
|
||||
lens_model: None,
|
||||
width: None,
|
||||
height: None,
|
||||
orientation: None,
|
||||
gps_latitude: None,
|
||||
gps_longitude: None,
|
||||
gps_altitude: None,
|
||||
focal_length: None,
|
||||
aperture: None,
|
||||
shutter_speed: None,
|
||||
iso: None,
|
||||
date_taken: Some(date_utc),
|
||||
created_time: 0,
|
||||
last_modified: 0,
|
||||
content_hash: None,
|
||||
size_bytes: None,
|
||||
phash_64: None,
|
||||
dhash_64: None,
|
||||
duplicate_of_hash: None,
|
||||
duplicate_decided_at: None,
|
||||
date_taken_source: Some("exif".to_string()),
|
||||
original_date_taken: None,
|
||||
original_date_taken_source: None,
|
||||
clip_embedding: None,
|
||||
clip_model_version: None,
|
||||
date_utc: Some(date_utc),
|
||||
camera_offset_minutes: Some(540),
|
||||
date_taken_tz_source: Some("offset_tag".to_string()),
|
||||
original_date_utc: None,
|
||||
original_camera_offset_minutes: None,
|
||||
original_date_taken_tz_source: None,
|
||||
};
|
||||
|
||||
let ny = chrono_tz::Tz::America__New_York;
|
||||
|
||||
// The prompt/UI date is the camera wall: 2024-07-06 08:30 JST —
|
||||
// one day AFTER the owner's display-zone date (midnight rollover
|
||||
// is correct here, the AI must see what the camera saw).
|
||||
let ai_date = resolve_date_taken_for_context(&Some(row.clone()), "tokyo.jpg", ny, Some(ny));
|
||||
assert_eq!(ai_date.as_deref(), Some("2024-07-06"));
|
||||
|
||||
// The proximity windows (SMS / location / calendar) anchor on the
|
||||
// true instant — no +9 h, no -4 h, nothing.
|
||||
let anchor = crate::date_view::row_true_instant(&row, ny, Some(ny));
|
||||
assert_eq!(anchor, Some(date_utc));
|
||||
}
|
||||
|
||||
/// AC-6: an unconverted container video row — the AI context date is
|
||||
/// the display-zone rendering of the true instant (the raw UTC creation
|
||||
/// time), matching the metadata/UI date. No 4–5 h offset, no midnight
|
||||
/// rollover relative to the UTC-naive reading.
|
||||
#[test]
|
||||
fn unconverted_video_row_ai_date_matches_display_wall() {
|
||||
use chrono::NaiveDate;
|
||||
|
||||
// The container's raw creation string is "2024-07-06 01:30:00"
|
||||
// (a true UTC instant). The old server parsed that naive string
|
||||
// as server-local (EDT) → legacy epoch = 05:30 UTC. The AI must
|
||||
// be told the owner's evening — July 5 — not July 6 (the UTC
|
||||
// date the old code would have rendered).
|
||||
let legacy = NaiveDate::from_ymd_opt(2024, 7, 6)
|
||||
.unwrap()
|
||||
.and_hms_opt(5, 30, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp();
|
||||
|
||||
let row = Some(crate::database::models::ImageExif {
|
||||
id: 0,
|
||||
library_id: 1,
|
||||
file_path: "movie.mp4".to_string(),
|
||||
camera_make: None,
|
||||
camera_model: None,
|
||||
lens_model: None,
|
||||
width: None,
|
||||
height: None,
|
||||
orientation: None,
|
||||
gps_latitude: None,
|
||||
gps_longitude: None,
|
||||
gps_altitude: None,
|
||||
focal_length: None,
|
||||
aperture: None,
|
||||
shutter_speed: None,
|
||||
iso: None,
|
||||
date_taken: Some(legacy),
|
||||
created_time: 0,
|
||||
last_modified: 0,
|
||||
content_hash: None,
|
||||
size_bytes: None,
|
||||
phash_64: None,
|
||||
dhash_64: None,
|
||||
duplicate_of_hash: None,
|
||||
duplicate_decided_at: None,
|
||||
date_taken_source: Some("exiftool".to_string()),
|
||||
original_date_taken: None,
|
||||
original_date_taken_source: None,
|
||||
clip_embedding: None,
|
||||
clip_model_version: None,
|
||||
date_utc: None,
|
||||
camera_offset_minutes: None,
|
||||
date_taken_tz_source: None,
|
||||
original_date_utc: None,
|
||||
original_camera_offset_minutes: None,
|
||||
original_date_taken_tz_source: None,
|
||||
});
|
||||
|
||||
let ny = chrono_tz::Tz::America__New_York;
|
||||
let ai_date = resolve_date_taken_for_context(&row, "movie.mp4", ny, Some(ny));
|
||||
assert_eq!(ai_date.as_deref(), Some("2024-07-05"));
|
||||
}
|
||||
}
|
||||
+89
-59
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user