Skip to main content

sokuto_server/guardrails/
temporal.rs

1//! # 相対日時正規化・時間的接地モジュール (Temporal Grounding)
2//!
3//! 小規模言語モデルの弱点 (Jaggedness) である相対日時の解釈誤差を低減するため、
4//! 「今日」「3日前」「先週」などの相対日時表現を、基準タイムスタンプに基づき
5//! 決定論的に絶対日付注記付きの表現へとインライン正規化する。
6
7use std::borrow::Cow;
8use std::sync::LazyLock;
9
10use chrono::{DateTime, Duration, Utc};
11use regex::Regex;
12
13/// 日本語および英語の相対日時表現を検出する正規表現。
14/// 既に `(YYYY-MM-DD)` のような注記が付与されている場合は二重注記を防止する。
15static 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/// 相対日時正規化設定構造体。
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct TemporalConfig {
23    /// 相対日時表現のインライン絶対化を有効にするか。
24    pub enable_inline_normalization: bool,
25    /// State 末尾に基準時刻メタデータ (`[Reference Time: ...]`) を付加するか。
26    pub attach_reference_time: bool,
27    /// 基準日時のタイムゾーンオフセット (時間単位、例: JST は +9、UTC は 0)。
28    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
41/// 相対日時表現を絶対日付に変換する。
42fn 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    // N日前 / N日後 / N days ago / N days later
72    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
99/// 相対日時表現を正規化する。
100///
101/// # 引数
102/// - `text`: 入力テキスト。
103/// - `ref_time`: 基準日時 (リクエスト受付時刻など)。
104/// - `config`: 相対日時設定。
105pub 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    // 相対日時パターンが含まれている可能性があるか高速事前チェック
117    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(&current, |caps: &regex::Captures| {
127            // 既に注記が含まれている場合はそのままスキップ
128            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
150/// State の末尾に基準時刻メタデータブロックを付加する。
151///
152/// 設定されたタイムゾーンオフセットを反映した ISO 8601 形式で出力する。
153pub 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        // UTC 2026-09-21 23:00:00 は、JST (UTC+9) では 2026-09-22 08:00:00 (翌朝)
205        let ref_time_utc = Utc.with_ymd_and_hms(2026, 9, 21, 23, 0, 0).unwrap();
206
207        // 1. UTC (offset = 0) の場合、今日は 2026-09-21
208        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        // 2. JST (offset = +9) の場合、日付境界を越えて今日は 2026-09-22
216        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        // メタデータヘッダーも +09:00 の形式で出力されること
224        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}