From edfa48d1169c498827d6341facb6b7f3e5aac195 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Sun, 18 Oct 2026 20:33:00 -0400 Subject: [PATCH] feat(ai): date_info tool for exact date arithmetic Small local models get weekdays, "how long ago" and day counts between dates wrong, confidently. Persona chats anchored on old SMS threads ask exactly those questions. date_info answers them exactly, in the owner's display zone: - no args: now (replaces get_current_datetime) - date: its weekday and how long ago / until - date + other_date: the gap in days, weeks and years/months/days, plus hours when both carry times - date + add_days: the date N days after (or before) Checked live on gemma4-26b-A4B and gemma4-31: each date question took one date_info call and came back correct. Co-Authored-By: Claude Opus 5.5 --- src/ai/date_info.rs | 350 ++++++++++++++++++++++++++++++++++++ src/ai/insight_generator.rs | 46 ++--- src/ai/mod.rs | 1 + src/reels/script.rs | 4 +- 4 files changed, 378 insertions(+), 23 deletions(-) create mode 100644 src/ai/date_info.rs diff --git a/src/ai/date_info.rs b/src/ai/date_info.rs new file mode 100644 index 0000000..e6f79e0 --- /dev/null +++ b/src/ai/date_info.rs @@ -0,0 +1,350 @@ +//! The `date_info` agent tool: date arithmetic the model shouldn't do in +//! its head. +//! +//! Small local models are reliably shaky at weekday lookups, "how long ago +//! was …" across year boundaries and day counts between two dates — and +//! they state the wrong answer confidently. Persona chats anchored on old +//! SMS threads ask exactly these questions, so the tool answers them +//! exactly. It also reports "now", replacing the old `get_current_datetime`. +//! +//! Everything is computed in the owner's display zone, so the dates match +//! the timestamps the model reads in thread transcripts and photo metadata. + +use chrono::{DateTime, Datelike, Months, NaiveDate, NaiveDateTime, Utc}; +use chrono_tz::Tz; + +/// A date argument as the model gave it. A bare date stays a date (no +/// invented time of day); a date with a time keeps it. +#[derive(Debug, Clone, Copy, PartialEq)] +enum Moment { + Day(NaiveDate), + At(NaiveDateTime), +} + +impl Moment { + fn date(self) -> NaiveDate { + match self { + Moment::Day(d) => d, + Moment::At(dt) => dt.date(), + } + } + + fn label(self) -> String { + match self { + Moment::Day(d) => format!("{} ({})", d.format("%Y-%m-%d"), d.format("%A")), + Moment::At(dt) => format!("{} ({})", dt.format("%Y-%m-%d %H:%M"), dt.format("%A")), + } + } +} + +/// Parse `YYYY-MM-DD`, `YYYY-MM-DD HH:MM` (or with `T` / seconds), or unix +/// seconds (read in `zone`). +fn parse_moment(value: &serde_json::Value, zone: Tz) -> Option { + if let Some(ts) = value.as_i64() { + let utc = DateTime::::from_timestamp(ts, 0)?; + return Some(Moment::At(utc.with_timezone(&zone).naive_local())); + } + let raw = value.as_str()?.trim(); + for fmt in [ + "%Y-%m-%d %H:%M", + "%Y-%m-%dT%H:%M", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%dT%H:%M:%S", + ] { + if let Ok(dt) = NaiveDateTime::parse_from_str(raw, fmt) { + return Some(Moment::At(dt)); + } + } + if let Ok(ts) = raw.parse::() { + return parse_moment(&serde_json::json!(ts), zone); + } + NaiveDate::parse_from_str(raw, "%Y-%m-%d") + .ok() + .map(Moment::Day) +} + +/// Whole years, months and days from `a` to `b` (`a <= b`), counted the +/// calendar way: Jan 31 + 1 month is Feb 28/29, as people count ages. +fn calendar_gap(a: NaiveDate, b: NaiveDate) -> (u32, u32, u32) { + let mut months = (b.year() - a.year()) * 12 + b.month() as i32 - a.month() as i32; + let mut anchor = a.checked_add_months(Months::new(months.max(0) as u32)); + if anchor.is_none_or(|d| d > b) { + months -= 1; + anchor = a.checked_add_months(Months::new(months.max(0) as u32)); + } + let months = months.max(0) as u32; + let days = anchor.map(|d| (b - d).num_days()).unwrap_or(0).max(0) as u32; + (months / 12, months % 12, days) +} + +fn plural(n: impl Into, unit: &str) -> String { + let n = n.into(); + if n == 1 { + format!("1 {unit}") + } else { + format!("{n} {unit}s") + } +} + +/// "7 years, 3 months and 15 days" — zero parts dropped, "0 days" when the +/// dates are the same. +fn describe_gap(a: NaiveDate, b: NaiveDate) -> String { + let (y, m, d) = calendar_gap(a, b); + let mut parts = Vec::new(); + if y > 0 { + parts.push(plural(y, "year")); + } + if m > 0 { + parts.push(plural(m, "month")); + } + if d > 0 || parts.is_empty() { + parts.push(plural(d, "day")); + } + match parts.len() { + 1 => parts.remove(0), + _ => { + let last = parts.pop().unwrap_or_default(); + format!("{} and {}", parts.join(", "), last) + } + } +} + +/// The span between two dates: a headline phrase, plus a detail (exact +/// day count, weeks) the caller places after any "ago" / "in". +fn span(a: NaiveDate, b: NaiveDate) -> (String, String) { + let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; + let days = (hi - lo).num_days(); + let weeks = if days >= 14 { + format!("about {}", plural(days / 7, "week")) + } else { + String::new() + }; + if days < 31 { + (plural(days, "day"), weeks) + } else { + let detail = if weeks.is_empty() { + plural(days, "day") + } else { + format!("{}, {weeks}", plural(days, "day")) + }; + (describe_gap(lo, hi), detail) + } +} + +/// `headline` with `(detail)` appended when there is one. +fn with_detail(headline: String, detail: &str) -> String { + if detail.is_empty() { + headline + } else { + format!("{headline} ({detail})") + } +} + +/// Run the tool. `now` is injected so tests are deterministic. +pub fn date_info(args: &serde_json::Value, now: DateTime, zone: Tz) -> String { + let now_local = now.with_timezone(&zone); + let today = now_local.date_naive(); + let mut out = format!( + "Now: {} ({}), {}", + now_local.format("%Y-%m-%d %H:%M"), + now_local.format("%A"), + zone.name() + ); + + let first = args.get("date").filter(|v| !v.is_null()); + let second = args.get("other_date").filter(|v| !v.is_null()); + let add_days = args.get("add_days").and_then(|v| v.as_i64()); + let Some(first) = first else { + return out; + }; + let Some(a) = parse_moment(first, zone) else { + return format!( + "Error: couldn't read date {first} — use YYYY-MM-DD, YYYY-MM-DD HH:MM or unix seconds" + ); + }; + + // Offset: "what date is N days after/before X". + if let Some(n) = add_days { + let Some(result) = a.date().checked_add_signed(chrono::Duration::days(n)) else { + return "Error: add_days is out of range".to_string(); + }; + let direction = if n >= 0 { "after" } else { "before" }; + out.push_str(&format!( + "\n{} {direction} {} is {}.", + plural(n.abs(), "day"), + a.label(), + Moment::Day(result).label() + )); + return out; + } + + match second { + None => { + let d = a.date(); + let relation = match d.cmp(&today) { + std::cmp::Ordering::Equal => "today".to_string(), + std::cmp::Ordering::Less => { + let (head, detail) = span(d, today); + with_detail(format!("{head} ago"), &detail) + } + std::cmp::Ordering::Greater => { + let (head, detail) = span(today, d); + with_detail(format!("in {head}"), &detail) + } + }; + out.push_str(&format!("\n{} — {relation}.", a.label())); + } + Some(second) => { + let Some(b) = parse_moment(second, zone) else { + return format!( + "Error: couldn't read other_date {second} — use YYYY-MM-DD, YYYY-MM-DD HH:MM or unix seconds" + ); + }; + let (from, to) = if a.date() <= b.date() { (a, b) } else { (b, a) }; + let mut gap = if from.date() == to.date() { + "the same day".to_string() + } else { + let (head, detail) = span(from.date(), to.date()); + with_detail(head, &detail) + }; + // Both with times and close together: the hours are what matter. + if let (Moment::At(x), Moment::At(y)) = (from, to) { + let minutes = (y - x).num_minutes().abs(); + if minutes < 48 * 60 { + gap.push_str(&format!(" ({}h {}m apart)", minutes / 60, minutes % 60)); + } + } + out.push_str(&format!( + "\nFrom {} to {}: {gap}.", + from.label(), + to.label() + )); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + use serde_json::json; + + const NY: Tz = chrono_tz::America::New_York; + + fn at(zone: Tz, y: i32, m: u32, d: u32, h: u32, min: u32) -> DateTime { + zone.with_ymd_and_hms(y, m, d, h, min, 0) + .single() + .expect("unambiguous local time") + .with_timezone(&Utc) + } + + fn now() -> DateTime { + // Sunday 2026-10-18 20:15 in New York. + at(NY, 2026, 10, 18, 20, 15) + } + + #[test] + fn no_date_reports_now_in_the_display_zone() { + assert_eq!( + date_info(&json!({}), now(), NY), + "Now: 2026-10-18 20:15 (Sunday), America/New_York" + ); + } + + #[test] + fn a_past_date_gets_its_weekday_and_how_long_ago() { + let out = date_info(&json!({"date": "2019-07-03"}), now(), NY); + assert!( + out.ends_with( + "2019-07-03 (Wednesday) — 7 years, 3 months and 15 days ago (2664 days, about 380 weeks)." + ), + "{out}" + ); + } + + #[test] + fn near_dates_read_as_days() { + let out = date_info(&json!({"date": "2026-10-15"}), now(), NY); + assert!( + out.ends_with("2026-10-15 (Thursday) — 3 days ago."), + "{out}" + ); + let out = date_info(&json!({"date": "2026-10-18"}), now(), NY); + assert!(out.ends_with("— today."), "{out}"); + let out = date_info(&json!({"date": "2026-11-01"}), now(), NY); + assert!(out.ends_with("— in 14 days (about 2 weeks)."), "{out}"); + } + + #[test] + fn today_follows_the_display_zone_not_utc() { + // 02:30 UTC on the 19th is still the evening of the 18th in New York. + let late = at(NY, 2026, 10, 18, 22, 30); + let out = date_info(&json!({"date": "2026-10-18"}), late, NY); + assert!(out.ends_with("— today."), "{out}"); + } + + #[test] + fn unix_seconds_read_in_the_display_zone() { + // 2019-07-04 02:47 UTC = 2019-07-03 22:47 in New York. + let out = date_info(&json!({"date": 1562208420}), now(), NY); + assert!(out.contains("2019-07-03 22:47 (Wednesday)"), "{out}"); + } + + #[test] + fn two_dates_give_the_gap_in_either_order() { + let a = date_info( + &json!({"date": "2019-07-05", "other_date": "2019-07-03"}), + now(), + NY, + ); + assert!( + a.ends_with("From 2019-07-03 (Wednesday) to 2019-07-05 (Friday): 2 days."), + "{a}" + ); + } + + #[test] + fn two_close_times_include_the_hours() { + let out = date_info( + &json!({"date": "2019-07-03 22:47", "other_date": "2019-07-04 08:40"}), + now(), + NY, + ); + assert!( + out.ends_with("2019-07-04 08:40 (Thursday): 1 day (9h 53m apart)."), + "{out}" + ); + } + + #[test] + fn add_days_moves_forward_and_back() { + let out = date_info(&json!({"date": "2019-07-03", "add_days": 90}), now(), NY); + assert!( + out.ends_with("90 days after 2019-07-03 (Wednesday) is 2019-10-01 (Tuesday)."), + "{out}" + ); + let out = date_info(&json!({"date": "2024-03-01", "add_days": -1}), now(), NY); + assert!(out.ends_with("is 2024-02-29 (Thursday)."), "{out}"); + } + + #[test] + fn calendar_gap_counts_like_people_do() { + let d = |y, m, dd| NaiveDate::from_ymd_opt(y, m, dd).unwrap(); + // Month-end clamps: Jan 31 + 1 month = Feb 29 in a leap year. + assert_eq!(calendar_gap(d(2020, 1, 31), d(2020, 2, 29)), (0, 1, 0)); + assert_eq!(calendar_gap(d(2020, 1, 31), d(2020, 3, 1)), (0, 1, 1)); + assert_eq!(calendar_gap(d(2016, 2, 29), d(2026, 2, 28)), (10, 0, 0)); + assert_eq!(calendar_gap(d(2019, 7, 3), d(2026, 10, 18)), (7, 3, 15)); + assert_eq!(describe_gap(d(2025, 10, 18), d(2026, 10, 18)), "1 year"); + } + + #[test] + fn bad_input_says_what_format_to_use() { + let out = date_info(&json!({"date": "last tuesday"}), now(), NY); + assert!( + out.starts_with("Error:") && out.contains("YYYY-MM-DD"), + "{out}" + ); + } +} diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index dc22f53..11e9644 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -1937,7 +1937,9 @@ Return ONLY the summary, nothing else."#, self.tool_supersede_fact(arguments, user_id, persona_id, model, backend_label) .await } - "get_current_datetime" => Self::tool_get_current_datetime(), + "date_info" => { + crate::ai::date_info::date_info(arguments, Utc::now(), self.display_zone()) + } "get_thread_messages" => self.tool_get_thread_messages(arguments).await, "look_at_message_image" => { self.tool_look_at_message_image(arguments, backend.local()) @@ -3703,21 +3705,11 @@ Return ONLY the summary, nothing else."#, } } - /// Tool: get_current_datetime — returns the current local date and time - fn tool_get_current_datetime() -> String { - let now = Local::now(); - format!( - "Current date/time: {} ({})", - now.format("%Y-%m-%d %H:%M:%S %Z"), - now.format("%A") - ) - } - // ── Agentic insight generation ────────────────────────────────────── /// Build the list of tool definitions for the agentic loop, gated by /// `opts`. Always-on tools: `search_messages`, `get_sms_messages`, - /// `get_file_tags`, `reverse_geocode`, `get_current_datetime`, the + /// `get_file_tags`, `reverse_geocode`, `date_info`, the /// five knowledge-memory tools. Conditional: `describe_photo` (vision /// model), `get_personal_place_at` (Apollo configured), `search_rag` /// (daily_summaries populated), `get_calendar_events` (calendar @@ -4043,11 +4035,23 @@ Return ONLY the summary, nothing else."#, } tools.push(Tool::function( - "get_current_datetime", - "Get the current date and time. Useful when reasoning about how long ago a photo was taken.", + "date_info", + "Exact date arithmetic — use it instead of working dates out yourself. With no arguments: \ + the current date and time. With `date`: that day's weekday and how long ago (or until) it \ + is. With `date` and `other_date`: the gap between them in days, weeks and years/months/days \ + (and hours, when both have times). With `date` and `add_days`: the date that many days \ + after (or, negative, before). Dates are YYYY-MM-DD, YYYY-MM-DD HH:MM, or unix seconds, in \ + the user's local time. \ + Examples: `{}` — now. `{date: \"2019-07-03\"}` — weekday and how long ago. \ + `{date: \"2019-07-03\", other_date: \"2019-08-14\"}` — time between. \ + `{date: \"2019-07-03\", add_days: 90}` — the date 90 days later.", serde_json::json!({ "type": "object", - "properties": {} + "properties": { + "date": { "type": "string", "description": "YYYY-MM-DD, YYYY-MM-DD HH:MM, or unix seconds." }, + "other_date": { "type": "string", "description": "Second date, to measure the gap between the two." }, + "add_days": { "type": "integer", "description": "Days to add to `date` (negative to go back)." } + } }), )); @@ -4305,7 +4309,7 @@ Return ONLY the summary, nothing else."#, .find_map(|tok| tok.strip_prefix("ID:")) .map(|id| format!("stored id={}", id.trim_end_matches(','))) .unwrap_or_else(|| "stored".to_string()), - "get_current_datetime" => "time noted".to_string(), + "date_info" => "date worked out".to_string(), "look_at_message_image" | "look_at_photo" => { let short: String = raw.chars().take(80).collect(); format!("looked: \"{}...\"", short) @@ -5238,7 +5242,7 @@ mod tests { assert!(names.contains(&"get_sms_messages")); assert!(names.contains(&"get_file_tags")); assert!(names.contains(&"reverse_geocode")); - assert!(names.contains(&"get_current_datetime")); + assert!(names.contains(&"date_info")); assert!(names.contains(&"recall_entities")); assert!(names.contains(&"recall_facts_for_photo")); assert!(names.contains(&"recall_facts_for_entity")); @@ -5786,13 +5790,13 @@ mod tests { } #[test] - fn summarize_current_datetime() { + fn summarize_date_info() { assert_eq!( InsightGenerator::summarize_tool_result( - "get_current_datetime", - "Current date/time: 2024-01-15 12:00:00 PST (Monday)" + "date_info", + "Now: 2024-01-15 12:00 (Monday), America/New_York" ), - "time noted" + "date worked out" ); } diff --git a/src/ai/mod.rs b/src/ai/mod.rs index 1a35364..f491517 100644 --- a/src/ai/mod.rs +++ b/src/ai/mod.rs @@ -2,6 +2,7 @@ pub mod apollo_client; pub mod backend; pub mod clip_client; pub mod daily_summary_job; +pub mod date_info; pub mod face_client; pub mod gpu; pub mod handlers; diff --git a/src/reels/script.rs b/src/reels/script.rs index 38ef9cc..8fff21a 100644 --- a/src/reels/script.rs +++ b/src/reels/script.rs @@ -55,7 +55,7 @@ can be read aloud in a few seconds. Avoid generic filler like \"what a \ wonderful day\" — if you have little to go on, simply describe the moment \ plainly.\n\nYou may call read-only tools (search_rag, search_messages, \ get_sms_messages, get_calendar_events, get_location_history, reverse_geocode, \ -get_personal_place_at, recall_entities, get_current_datetime) to ground each \ +get_personal_place_at, recall_entities, date_info) to ground each \ line in real context — e.g. reverse_geocode a moment's GPS to name the place, \ or check the calendar/messages around its date. Never invent details. Return \ ONLY the JSON object, no prose or code fences."; @@ -335,7 +335,7 @@ pub async fn generate_script_agentic( "reverse_geocode", "get_personal_place_at", "recall_entities", - "get_current_datetime", + "date_info", ] .into_iter() .collect();