feat(dates): photo date filters on the true instant (#152)
date_from / date_to on /photos, /photos/exif and unified search ran on the legacy mixed-convention date_taken, so a photo could display inside a range yet be filtered out of it by up to its UTC offset (or by years, for rows the filename class re-dated). The filter lives in the shared ExifDao::query_by_exif, so it's fixed once there: - query_by_exif takes Option<date_view::InstantRange> (bounds + display and server zones) instead of raw date_from / date_to. - SQL pre-filter: converted rows on date_utc exactly; unconverted rows (drain pending, permanent floor) on date_taken widened by LEGACY_INSTANT_BAND_SECS (±15 h, now shared with /memories). Converted rows are deliberately not bounded by date_taken: a re-dated row's legacy value can sit years from its instant. - Exact pass in Rust via InstantRange::contains → row_true_instant, so every returned row's true instant is inside the range. Calendar days now mean the owner's days: nl_query::iso_to_unix builds day bounds in the display zone (it used UTC midnights, which only lined up with the legacy wall-as-UTC column), and unified search's "today" is the owner's today rather than UTC's. The midnight-with-DST-gap fallback moves from memories_anchor into date_view::day_start_instant. Compatibility: clients already sending real instants (Apollo's /photos/exif windows) get correct edges with no change. Clients sending UTC-midnight day bounds see ranges shift by up to the UTC offset until they send display-zone bounds. No index on date_utc; the OR clause scans, which is milliseconds at personal-library scale (same as /memories today). dev/e2e check gains /photos/exif wall_clock + true_instant assertions and range-edge checks ([t, t] in, [.., t-1] and [t+1, ..] out). Not run here: the harness needs the WSL + Windows toolchain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9e780774d9
commit
19fbf43d40
12 files changed
+478
-90
No files matched your search
+72
-33
@@ -88,18 +88,20 @@ pub struct StructuredQuery {
|
||||
pub media_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Convert an ISO `YYYY-MM-DD` date to a unix timestamp (seconds). With
|
||||
/// `end_of_day`, returns 23:59:59 of that day so a `date_to` filter is
|
||||
/// inclusive of the whole day; otherwise 00:00:00. Returns `None` for any
|
||||
/// unparseable input (the filter is simply omitted rather than erroring).
|
||||
pub fn iso_to_unix(date: &str, end_of_day: bool) -> Option<i64> {
|
||||
/// Convert an ISO `YYYY-MM-DD` date to the true instant (unix seconds) that
|
||||
/// day starts at in the owner's display `zone`. With `end_of_day`, returns
|
||||
/// the last second of that day instead, so a `date_to` filter is inclusive
|
||||
/// of the whole day. The photo filters compare true instants (ADR-0001,
|
||||
/// #152), so "June" means the owner's June — the same days `/memories`
|
||||
/// groups by. Returns `None` for any unparseable input (the filter is
|
||||
/// simply omitted rather than erroring).
|
||||
pub fn iso_to_unix(date: &str, end_of_day: bool, zone: chrono_tz::Tz) -> Option<i64> {
|
||||
let d = chrono::NaiveDate::parse_from_str(date.trim(), "%Y-%m-%d").ok()?;
|
||||
let time = if end_of_day {
|
||||
chrono::NaiveTime::from_hms_opt(23, 59, 59)?
|
||||
if end_of_day {
|
||||
Some(crate::date_view::day_start_instant(d.succ_opt()?, zone) - 1)
|
||||
} else {
|
||||
chrono::NaiveTime::from_hms_opt(0, 0, 0)?
|
||||
};
|
||||
Some(d.and_time(time).and_utc().timestamp())
|
||||
Some(crate::date_view::day_start_instant(d, zone))
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a free-form media-type string to the engine's vocabulary.
|
||||
@@ -122,7 +124,11 @@ fn normalize_media_type(raw: &str) -> Option<String> {
|
||||
/// `tag_vocab` is `(tag_id, tag_name)` pairs (the shape `TagDao::get_all_tags`
|
||||
/// yields once the count is dropped). Matching is case-insensitive and exact
|
||||
/// on the trimmed name.
|
||||
pub fn resolve_raw_query(raw: RawNlQuery, tag_vocab: &[(i32, String)]) -> StructuredQuery {
|
||||
pub fn resolve_raw_query(
|
||||
raw: RawNlQuery,
|
||||
tag_vocab: &[(i32, String)],
|
||||
display_zone: chrono_tz::Tz,
|
||||
) -> StructuredQuery {
|
||||
// Case-insensitive name → id lookup. Built once per call.
|
||||
let lookup: std::collections::HashMap<String, i32> = tag_vocab
|
||||
.iter()
|
||||
@@ -173,8 +179,14 @@ pub fn resolve_raw_query(raw: RawNlQuery, tag_vocab: &[(i32, String)]) -> Struct
|
||||
camera_model: clean(raw.camera_model),
|
||||
lens_model: clean(raw.lens_model),
|
||||
place: clean(raw.place),
|
||||
date_from: raw.date_from.as_deref().and_then(|d| iso_to_unix(d, false)),
|
||||
date_to: raw.date_to.as_deref().and_then(|d| iso_to_unix(d, true)),
|
||||
date_from: raw
|
||||
.date_from
|
||||
.as_deref()
|
||||
.and_then(|d| iso_to_unix(d, false, display_zone)),
|
||||
date_to: raw
|
||||
.date_to
|
||||
.as_deref()
|
||||
.and_then(|d| iso_to_unix(d, true, display_zone)),
|
||||
media_type: raw.media_type.as_deref().and_then(normalize_media_type),
|
||||
}
|
||||
}
|
||||
@@ -243,27 +255,34 @@ fn extract_json(raw: &str) -> String {
|
||||
|
||||
/// Parse a model response string into a [`StructuredQuery`], resolving names
|
||||
/// against the vocab. Separated from the LLM call so it's unit-testable.
|
||||
pub fn parse_response(response: &str, tag_vocab: &[(i32, String)]) -> Result<StructuredQuery> {
|
||||
pub fn parse_response(
|
||||
response: &str,
|
||||
tag_vocab: &[(i32, String)],
|
||||
display_zone: chrono_tz::Tz,
|
||||
) -> Result<StructuredQuery> {
|
||||
let json = extract_json(response);
|
||||
let raw: RawNlQuery = serde_json::from_str(&json)
|
||||
.map_err(|e| anyhow!("failed to parse NL query JSON: {e}; raw response: {response:?}"))?;
|
||||
Ok(resolve_raw_query(raw, tag_vocab))
|
||||
Ok(resolve_raw_query(raw, tag_vocab, display_zone))
|
||||
}
|
||||
|
||||
/// Translate a natural-language query into a [`StructuredQuery`] via one
|
||||
/// grounded LLM call. The `client` is any configured backend (the unified
|
||||
/// endpoint passes the resolved chat backend); `tag_vocab` grounds the tag
|
||||
/// mapping; `today` anchors relative-date resolution.
|
||||
/// mapping; `today` (the owner's day, in `display_zone`) anchors
|
||||
/// relative-date resolution, and `display_zone` turns the model's ISO
|
||||
/// dates into day-bound instants.
|
||||
pub async fn translate_nl_query(
|
||||
client: &dyn LlmClient,
|
||||
nl: &str,
|
||||
tag_vocab: &[(i32, String)],
|
||||
today: chrono::NaiveDate,
|
||||
display_zone: chrono_tz::Tz,
|
||||
) -> Result<StructuredQuery> {
|
||||
let system = build_system_prompt(tag_vocab, today);
|
||||
let messages = vec![ChatMessage::system(system), ChatMessage::user(nl)];
|
||||
let (msg, _, _) = client.chat_with_tools(messages, Vec::<Tool>::new()).await?;
|
||||
parse_response(&msg.content, tag_vocab)
|
||||
parse_response(&msg.content, tag_vocab, display_zone)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -281,19 +300,33 @@ mod tests {
|
||||
#[test]
|
||||
fn iso_to_unix_start_and_end_of_day() {
|
||||
// 2023-01-01 UTC midnight = 1672531200.
|
||||
assert_eq!(iso_to_unix("2023-01-01", false), Some(1_672_531_200));
|
||||
assert_eq!(
|
||||
iso_to_unix("2023-01-01", false, chrono_tz::UTC),
|
||||
Some(1_672_531_200)
|
||||
);
|
||||
// End of that day is 86399 seconds later.
|
||||
assert_eq!(
|
||||
iso_to_unix("2023-01-01", true),
|
||||
iso_to_unix("2023-01-01", true, chrono_tz::UTC),
|
||||
Some(1_672_531_200 + 86_399)
|
||||
);
|
||||
}
|
||||
|
||||
/// #152: ISO dates become the owner's day bounds in the display zone,
|
||||
/// not UTC days, so "June" lines up with the true-instant filters.
|
||||
#[test]
|
||||
fn iso_to_unix_uses_display_zone_day_bounds() {
|
||||
let ny = chrono_tz::America::New_York;
|
||||
// 2023-06-01 00:00 EDT = 04:00Z.
|
||||
assert_eq!(iso_to_unix("2023-06-01", false, ny), Some(1_685_592_000));
|
||||
// Last second of 2023-06-30 EDT = 2023-07-01 03:59:59Z.
|
||||
assert_eq!(iso_to_unix("2023-06-30", true, ny), Some(1_688_183_999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_to_unix_rejects_garbage() {
|
||||
assert_eq!(iso_to_unix("last summer", false), None);
|
||||
assert_eq!(iso_to_unix("2023-13-99", false), None);
|
||||
assert_eq!(iso_to_unix("", false), None);
|
||||
assert_eq!(iso_to_unix("last summer", false, chrono_tz::UTC), None);
|
||||
assert_eq!(iso_to_unix("2023-13-99", false, chrono_tz::UTC), None);
|
||||
assert_eq!(iso_to_unix("", false, chrono_tz::UTC), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -302,7 +335,7 @@ mod tests {
|
||||
tags: vec!["BEACH".to_string(), "sunset".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let q = resolve_raw_query(raw, &vocab());
|
||||
let q = resolve_raw_query(raw, &vocab(), chrono_tz::UTC);
|
||||
assert_eq!(q.tag_ids, vec![1, 2]);
|
||||
assert!(q.unmatched_tags.is_empty());
|
||||
}
|
||||
@@ -315,7 +348,7 @@ mod tests {
|
||||
tags: vec!["beach".to_string(), "golden hour".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let q = resolve_raw_query(raw, &vocab());
|
||||
let q = resolve_raw_query(raw, &vocab(), chrono_tz::UTC);
|
||||
assert_eq!(q.tag_ids, vec![1]);
|
||||
assert_eq!(q.unmatched_tags, vec!["golden hour".to_string()]);
|
||||
}
|
||||
@@ -330,7 +363,7 @@ mod tests {
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let q = resolve_raw_query(raw, &vocab());
|
||||
let q = resolve_raw_query(raw, &vocab(), chrono_tz::UTC);
|
||||
assert_eq!(q.tag_ids, vec![1]);
|
||||
}
|
||||
|
||||
@@ -342,10 +375,13 @@ mod tests {
|
||||
date_to: Some("2023-06-30".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let q = resolve_raw_query(raw, &vocab());
|
||||
let q = resolve_raw_query(raw, &vocab(), chrono_tz::UTC);
|
||||
assert_eq!(q.media_type.as_deref(), Some("video"));
|
||||
assert_eq!(q.date_from, iso_to_unix("2023-06-01", false));
|
||||
assert_eq!(q.date_to, iso_to_unix("2023-06-30", true));
|
||||
assert_eq!(
|
||||
q.date_from,
|
||||
iso_to_unix("2023-06-01", false, chrono_tz::UTC)
|
||||
);
|
||||
assert_eq!(q.date_to, iso_to_unix("2023-06-30", true, chrono_tz::UTC));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -354,7 +390,10 @@ mod tests {
|
||||
media_type: Some("all".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(resolve_raw_query(raw, &vocab()).media_type, None);
|
||||
assert_eq!(
|
||||
resolve_raw_query(raw, &vocab(), chrono_tz::UTC).media_type,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -365,7 +404,7 @@ mod tests {
|
||||
place: Some("".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let q = resolve_raw_query(raw, &vocab());
|
||||
let q = resolve_raw_query(raw, &vocab(), chrono_tz::UTC);
|
||||
assert_eq!(q.semantic, None);
|
||||
assert_eq!(q.camera_make.as_deref(), Some("Fujifilm"));
|
||||
assert_eq!(q.place, None);
|
||||
@@ -374,7 +413,7 @@ mod tests {
|
||||
#[test]
|
||||
fn parse_response_handles_code_fences_and_prose() {
|
||||
let resp = "Here is the filter:\n```json\n{\"semantic\":\"sunset\",\"tags\":[\"beach\"]}\n```\nDone.";
|
||||
let q = parse_response(resp, &vocab()).expect("parse");
|
||||
let q = parse_response(resp, &vocab(), chrono_tz::UTC).expect("parse");
|
||||
assert_eq!(q.semantic.as_deref(), Some("sunset"));
|
||||
assert_eq!(q.tag_ids, vec![1]);
|
||||
}
|
||||
@@ -382,13 +421,13 @@ mod tests {
|
||||
#[test]
|
||||
fn parse_response_handles_think_block_then_json() {
|
||||
let resp = "<think>user wants beach sunsets</think>{\"tags\":[\"beach\",\"sunset\"]}";
|
||||
let q = parse_response(resp, &vocab()).expect("parse");
|
||||
let q = parse_response(resp, &vocab(), chrono_tz::UTC).expect("parse");
|
||||
assert_eq!(q.tag_ids, vec![1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_response_errors_on_non_json() {
|
||||
assert!(parse_response("I cannot help with that.", &vocab()).is_err());
|
||||
assert!(parse_response("I cannot help with that.", &vocab(), chrono_tz::UTC).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user