sokuto_server/guardrails/
temporal.rs1use std::borrow::Cow;
8use std::sync::LazyLock;
9
10use chrono::{DateTime, Duration, Utc};
11use regex::Regex;
12
13static RELATIVE_DAYS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
16 Regex::new(r"(?i)(?P<target>\b(?:today|yesterday|tomorrow|\d+\s*days?\s*ago|\d+\s*days?\s*later)\b|\d+\s*(?:日前|日まえ|営業日前|日後|日ご)|一昨日|おととい|昨日|きのう|今日|きょう|明日|あした|明後日|あさって)(?P<annotated>\s*[\((]\d{4}-\d{2}-\d{2}[\))])?")
17 .expect("相対日時正規表現のコンパイルに成功すること。")
18});
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct TemporalConfig {
23 pub enable_inline_normalization: bool,
25 pub attach_reference_time: bool,
27 pub timezone_offset_hours: i32,
29}
30
31impl Default for TemporalConfig {
32 fn default() -> Self {
33 Self {
34 enable_inline_normalization: true,
35 attach_reference_time: true,
36 timezone_offset_hours: 0,
37 }
38 }
39}
40
41fn resolve_relative_date(
43 target: &str,
44 ref_time: DateTime<Utc>,
45 offset_hours: i32,
46) -> Option<String> {
47 let lower = target.to_lowercase();
48 let local_time = ref_time + Duration::hours(offset_hours as i64);
49 let date = local_time.date_naive();
50
51 if target == "今日" || target == "きょう" || lower == "today" {
52 return Some(format!("{target} ({})", date.format("%Y-%m-%d")));
53 }
54 if target == "昨日" || target == "きのう" || lower == "yesterday" {
55 let target_date = date - Duration::days(1);
56 return Some(format!("{target} ({})", target_date.format("%Y-%m-%d")));
57 }
58 if target == "一昨日" || target == "おととい" {
59 let target_date = date - Duration::days(2);
60 return Some(format!("{target} ({})", target_date.format("%Y-%m-%d")));
61 }
62 if target == "明日" || target == "あした" || lower == "tomorrow" {
63 let target_date = date + Duration::days(1);
64 return Some(format!("{target} ({})", target_date.format("%Y-%m-%d")));
65 }
66 if target == "明後日" || target == "あさって" {
67 let target_date = date + Duration::days(2);
68 return Some(format!("{target} ({})", target_date.format("%Y-%m-%d")));
69 }
70
71 if target.contains("日前")
73 || target.contains("日まえ")
74 || lower.contains("days ago")
75 || lower.contains("day ago")
76 {
77 let num_str: String = target.chars().filter(|c| c.is_ascii_digit()).collect();
78 if let Ok(n) = num_str.parse::<i64>() {
79 let target_date = date - Duration::days(n);
80 return Some(format!("{target} ({})", target_date.format("%Y-%m-%d")));
81 }
82 }
83
84 if target.contains("日後")
85 || target.contains("日ご")
86 || lower.contains("days later")
87 || lower.contains("day later")
88 {
89 let num_str: String = target.chars().filter(|c| c.is_ascii_digit()).collect();
90 if let Ok(n) = num_str.parse::<i64>() {
91 let target_date = date + Duration::days(n);
92 return Some(format!("{target} ({})", target_date.format("%Y-%m-%d")));
93 }
94 }
95
96 None
97}
98
99pub fn normalize_temporal<'a>(
106 text: &'a str,
107 ref_time: DateTime<Utc>,
108 config: &TemporalConfig,
109) -> Cow<'a, str> {
110 if text.is_empty() || !config.enable_inline_normalization {
111 return Cow::Borrowed(text);
112 }
113
114 let mut current = Cow::Borrowed(text);
115
116 if current.contains("日")
118 || current.contains("今日")
119 || current.contains("昨日")
120 || current.contains("明日")
121 || current.to_lowercase().contains("today")
122 || current.to_lowercase().contains("yesterday")
123 || current.to_lowercase().contains("tomorrow")
124 || current.to_lowercase().contains("ago")
125 {
126 let replaced = RELATIVE_DAYS_REGEX.replace_all(¤t, |caps: ®ex::Captures| {
127 if caps.name("annotated").is_some() {
129 return caps[0].to_string();
130 }
131
132 let target = &caps["target"];
133 if let Some(resolved) =
134 resolve_relative_date(target, ref_time, config.timezone_offset_hours)
135 {
136 resolved
137 } else {
138 target.to_string()
139 }
140 });
141
142 if let Cow::Owned(s) = replaced {
143 current = Cow::Owned(s);
144 }
145 }
146
147 current
148}
149
150pub fn append_reference_time_metadata(
154 state: &mut String,
155 ref_time: DateTime<Utc>,
156 offset_hours: i32,
157) {
158 let offset_sec = (offset_hours * 3600).clamp(-86399, 86399);
159 let fixed_offset = chrono::FixedOffset::east_opt(offset_sec)
160 .unwrap_or_else(|| chrono::FixedOffset::east_opt(0).unwrap());
161 let local_dt = ref_time.with_timezone(&fixed_offset);
162 let date_str = if offset_hours == 0 {
163 ref_time.format("%Y-%m-%dT%H:%M:%SZ").to_string()
164 } else {
165 local_dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string()
166 };
167 let meta_block = format!("\n[Reference Time: {date_str}]");
168 state.push_str(&meta_block);
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use chrono::TimeZone;
175
176 #[test]
177 fn test_normalize_temporal_days() {
178 let config = TemporalConfig::default();
179 let ref_time = Utc.with_ymd_and_hms(2026, 9, 22, 12, 0, 0).unwrap();
180
181 let raw = "注文は3日前に発生し、昨日発送されました。";
182 let res = normalize_temporal(raw, ref_time, &config);
183 assert_eq!(
184 res,
185 "注文は3日前 (2026-09-19)に発生し、昨日 (2026-09-21)発送されました。"
186 );
187 }
188
189 #[test]
190 fn test_normalize_temporal_english() {
191 let config = TemporalConfig::default();
192 let ref_time = Utc.with_ymd_and_hms(2026, 9, 22, 12, 0, 0).unwrap();
193
194 let raw = "The report was updated 5 days ago and due tomorrow.";
195 let res = normalize_temporal(raw, ref_time, &config);
196 assert_eq!(
197 res,
198 "The report was updated 5 days ago (2026-09-17) and due tomorrow (2026-09-23)."
199 );
200 }
201
202 #[test]
203 fn test_normalize_temporal_with_timezone_offset() {
204 let ref_time_utc = Utc.with_ymd_and_hms(2026, 9, 21, 23, 0, 0).unwrap();
206
207 let config_utc = TemporalConfig {
209 timezone_offset_hours: 0,
210 ..Default::default()
211 };
212 let res_utc = normalize_temporal("今日は晴れ。", ref_time_utc, &config_utc);
213 assert_eq!(res_utc, "今日 (2026-09-21)は晴れ。");
214
215 let config_jst = TemporalConfig {
217 timezone_offset_hours: 9,
218 ..Default::default()
219 };
220 let res_jst = normalize_temporal("今日は晴れ。", ref_time_utc, &config_jst);
221 assert_eq!(res_jst, "今日 (2026-09-22)は晴れ。");
222
223 let mut state = "State内容".to_string();
225 append_reference_time_metadata(&mut state, ref_time_utc, 9);
226 assert!(state.contains("[Reference Time: 2026-09-22T08:00:00+09:00]"));
227 }
228}